DOCODIVE
Advanced Free Learning Path

JavaScript Advanced Course

Master the hardest parts of JavaScript with 25 advanced lessons. Deep-dive into closures internals, prototypes, generators, proxies, the event loop, Web Workers, design patterns, and performance — each with code, output, and official MDN docs.

5–7 weeks 25 lessons 2 projects 1 capstone Intermediate knowledge required
Start Learning
01

Execution Context & Call Stack

Every time a function is called, JavaScript creates an execution context that holds its variables, arguments, and the value of this. These contexts are pushed onto a call stack. When a function returns, its context is popped off. The call stack is why JavaScript is called single-threaded.

javascript
function first() {
  console.log("first start");
  second();
  console.log("first end");
}

function second() {
  console.log("second");
}

first();
first() is pushed onto the stack and logs "first start". It then calls second(), which is pushed on top. second() logs "second" and returns, so it is popped. Control returns to first(), which logs "first end" and then also pops.
console
first start second first end
The output order follows the stack: first started, then second ran completely, then first finished. This LIFO (last in, first out) order is the call stack in action.
02

Lexical Environment & Scope Chain

A lexical environment is the hidden structure that stores variables in a scope, along with a reference to its outer environment. When you access a variable, JavaScript walks this chain from inner to outer until it finds the variable or reaches the global scope.

javascript
let global = "global";

function outer() {
  let outerVar = "outer";

  function inner() {
    let innerVar = "inner";
    console.log(innerVar, outerVar, global);
  }

  inner();
}

outer();
global is in the global environment. outer() creates its own environment containing outerVar, and inner() creates one with innerVar. When inner logs, it finds innerVar locally, then walks up to outer for outerVar, then to global for global. This upward lookup is the scope chain.
console
inner outer global
The inner function could access variables from all three levels because each environment points to its parent. The scope chain allowed the lookup to climb from inner to outer to global.
03

`this` Binding Rules Deep Dive

The value of this is determined by four rules, in priority order: 1) new binding (constructor calls), 2) explicit binding (call, apply, bind), 3) implicit binding (method call), 4) default binding (global or undefined in strict mode). Arrow functions ignore these rules and inherit this from their surrounding scope.

javascript
const obj = {
  name: "Implicit",
  show() { return this.name; }
};

function show() { return this?.name ?? "Default"; }

console.log(obj.show());
console.log(show.call({ name: "Explicit" }));
console.log(show());
obj.show() uses implicit binding, so this is obj. show.call({...}) uses explicit binding, so this is the passed object. The bare show() call uses default binding, so this is undefined in strict mode (or global), and the fallback "Default" is returned.
console
Implicit Explicit Default
The three calls printed three different values because each used a different binding rule. This shows that this is not fixed at definition time — it is determined by how the function is called.
04

Prototypal Inheritance Patterns

JavaScript uses prototypes, not classical inheritance. Objects can inherit directly from other objects. The two main patterns are the factory pattern (Object.create) and the constructor/prototype pattern. Understanding the prototype chain is key to mastering JavaScript's object model.

javascript
const vehicle = {
  start() { return "Starting..."; }
};

const car = Object.create(vehicle);
car.drive = function() { return "Driving"; };

console.log(car.drive());
console.log(car.start());
console.log(Object.getPrototypeOf(car) === vehicle);
vehicle has a start method. Object.create(vehicle) creates car with vehicle as its prototype. car has its own drive method. Object.getPrototypeOf(car) returns vehicle, proving the prototype link.
console
Driving Starting... true
car.drive() used its own method. car.start() was found by walking up to vehicle. The strict equality check returned true, confirming that car's prototype is exactly vehicle.
05

Class Inheritance & Mixins

extends gives single inheritance: one class inherits from one parent. Mixins let you combine behavior from multiple sources by copying methods from several objects into a class prototype. Mixins use Object.assign to merge capabilities.

javascript
const canFly = {
  fly() { return "Flying"; }
};

const canSwim = {
  swim() { return "Swimming"; }
};

class Duck {}
Object.assign(Duck.prototype, canFly, canSwim);

const duck = new Duck();
console.log(duck.fly(), duck.swim());
Two mixin objects, canFly and canSwim, each define one method. The Duck class is empty. Object.assign copies both mixin methods onto Duck.prototype, so every Duck instance now has fly() and swim().
console
Flying Swimming
The duck instance could use both fly() and swim() because the mixin methods were merged into Duck's prototype. This is how you get multiple-inheritance-like behavior in JavaScript.
06

ES6 Private Class Fields (#)

Private class fields use the # prefix and are truly private — they cannot be accessed from outside the class, not even by subclasses. This gives real encapsulation, unlike the old convention of underscore-prefixed pseudo-private properties.

javascript
class BankAccount {
  #balance = 0;

  deposit(amount) {
    this.#balance += amount;
    return this.#balance;
  }

  getBalance() {
    return this.#balance;
  }
}

const acc = new BankAccount();
acc.deposit(100);
console.log(acc.getBalance());
console.log(acc.#balance);
#balance is a private field, only accessible inside the class. deposit() and getBalance() can read and change it. The final line tries to access acc.#balance from outside, which is not allowed.
console
100 SyntaxError: Private field '#balance' must be declared in an enclosing class
getBalance() returned 100 because it is inside the class. The direct outside access acc.#balance threw a SyntaxError, proving the field is truly private and cannot be reached from external code.
07

Symbols

Symbol is a primitive type used to create unique identifiers. Every Symbol() call returns a value guaranteed to be unique. Symbols are often used as object keys to avoid name collisions, and well-known symbols like Symbol.iterator customize object behavior.

javascript
const id1 = Symbol("id");
const id2 = Symbol("id");

const user = {
  [id1]: 123
};

console.log(id1 === id2);
console.log(user[id1]);
Symbol("id") is called twice, but each returns a different unique symbol even with the same description. id1 is used as a computed object key. The first log compares the two symbols, and the second reads the value stored under id1.
console
false 123
id1 and id2 are not equal even though they share the description "id" — every symbol is unique. The value 123 was retrieved using the original symbol key, showing symbols work as collision-free property keys.
08

Iterators & Iterables

An iterable is any object that defines a Symbol.iterator method returning an iterator. An iterator is an object with a next() method that returns { value, done }. Arrays, strings, maps, and sets are built-in iterables, which is why for...of works on them.

javascript
const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    return {
      next: () => {
        if (current <= this.to) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

for (const n of range) {
  console.log(n);
}
range has a Symbol.iterator method that returns an iterator object with a next() arrow function. next() returns the current value and increments it until it passes 'to', at which point it returns done: true. The for...of loop keeps calling next() until done is true.
console
1 2 3
The for...of loop used the custom iterator to produce 1, 2, and 3. Once done became true, the loop stopped. This is exactly how built-in iterables work under the hood.
09

Generators (function*)

A generator is a function that can pause and resume execution. It is declared with function* and uses the yield keyword to return values one at a time. Calling a generator returns an iterator, and each .next() call resumes execution until the next yield.

javascript
function* countUp() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = countUp();
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
countUp is a generator that yields three values. Calling countUp() returns an iterator stored in gen. Each gen.next() resumes execution and returns { value, done }. The first three calls return values with done false; the fourth returns undefined with done true.
console
{ value: 1, done: false } { value: 2, done: false } { value: 3, done: false } { value: undefined, done: true }
Each next() produced the next yielded value until the generator was exhausted. The final call returned done: true, signaling the generator has finished. This pausing behavior is what makes generators special.
10

Generator Use Cases (Lazy Evaluation)

Generators shine when you want lazy evaluation — producing values only when asked, instead of computing everything upfront. This is ideal for infinite sequences, large data streams, and custom control flow.

javascript
function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
for (let i = 0; i < 6; i++) {
  console.log(fib.next().value);
}
fibonacci is an infinite generator: it never exits its while(true) loop. Each yield produces the current Fibonacci number, then a and b are updated. The for loop only calls next() six times, so only six values are computed.
console
0 1 1 2 3 5
Only six Fibonacci numbers were generated because next() was called only six times. This is lazy evaluation — the infinite sequence computes values on demand instead of trying to build the entire list at once.
11

Proxies

A Proxy wraps an object and intercepts operations on it, like reading, writing, and deleting properties. A trap (handler function) runs instead of the default operation. Proxies enable validation, logging, and reactive programming.

javascript
const target = { name: "Ali", age: 25 };

const handler = {
  get(obj, prop) {
    console.log(`Reading ${prop}`);
    return obj[prop];
  },
  set(obj, prop, value) {
    if (prop === "age" && value < 0) {
      throw new Error("Age cannot be negative");
    }
    obj[prop] = value;
    return true;
  }
};

const proxy = new Proxy(target, handler);
console.log(proxy.name);
proxy.age = -5;
The handler defines a get trap that logs every read and a set trap that validates age. new Proxy(target, handler) creates the proxy. Reading proxy.name triggers get. Setting proxy.age to -5 triggers set, which throws because the value is negative.
console
Reading name Ali Error: Age cannot be negative
The get trap logged "Reading name" before returning Ali. The set trap rejected the negative age and threw an error. The proxy successfully intercepted both operations.
12

Reflect API

Reflect provides methods that mirror JavaScript's internal operations — like Reflect.get, Reflect.set, and Reflect.has — as normal functions. It is often used inside Proxy traps to perform the default behavior cleanly.

javascript
const obj = { name: "Ali" };

console.log(Reflect.get(obj, "name"));
console.log(Reflect.has(obj, "age"));
Reflect.set(obj, "age", 25);
console.log(Reflect.get(obj, "age"));
Reflect.get reads a property. Reflect.has checks if a property exists. Reflect.set writes a property. The first call reads name, the second checks for age (false), then age is set to 25, and the final call reads it back.
console
Ali false 25
Reflect.get returned the stored name. Reflect.has returned false because age did not exist yet. After Reflect.set added age, the final Reflect.get returned 25. Reflect turned these operations into explicit functions.
13

WeakMap & WeakSet

WeakMap and WeakSet are like Map and Set, but they hold weak references to their keys. If an object used as a key has no other references, it can be garbage collected. This prevents memory leaks when tracking objects.

javascript
let user = { name: "Ali" };
const visits = new WeakMap();

visits.set(user, 5);
console.log(visits.get(user));

user = null;
// The entry is now eligible for garbage collection
user is used as a WeakMap key with value 5. visits.get(user) retrieves 5. When user is set to null, the object has no strong references left, so the WeakMap entry can be garbage collected automatically.
console
5
The value 5 was retrieved while user still existed. After user was nulled, the WeakMap did not keep the object alive — that is the core difference from a regular Map, which would prevent garbage collection.
14

Memory Management & Garbage Collection

JavaScript automatically manages memory using garbage collection. The main algorithm is mark-and-sweep: objects that are reachable from roots (global object, call stack) are kept; unreachable objects are freed. You can help by removing references when data is no longer needed.

javascript
function createData() {
  let big = { data: new Array(1000).fill("x") };
  return big;
}

let holder = createData();
console.log(holder.data.length);

holder = null; // release the reference
createData returns an object holding a large array. holder keeps a reference to it, so it is reachable. After holder = null, there are no references left, making the object unreachable and eligible for garbage collection.
console
1000
The length 1000 printed while the object was still referenced. Setting holder to null broke the reference, allowing the garbage collector to reclaim the memory later. This is the manual half of automatic memory management.
15

Event Loop Deep Dive (Sync → Micro → Macro)

The event loop processes tasks in a precise order: all synchronous code runs first, then all microtasks (Promises, queueMicrotask) are drained, then one macrotask (setTimeout, I/O) runs, and the cycle repeats. Understanding this order prevents subtle bugs.

javascript
console.log("1: sync");

setTimeout(() => console.log("4: macrotask"), 0);

Promise.resolve().then(() => {
  console.log("2: microtask");
  queueMicrotask(() => console.log("3: nested microtask"));
});

console.log("5: sync end");
The two synchronous logs run first. The Promise .then and queueMicrotask are microtasks, queued before macrotasks. setTimeout is a macrotask. The event loop drains all microtasks before running any macrotask, even with a 0ms delay.
console
1: sync 5: sync end 2: microtask 3: nested microtask 4: macrotask
Sync code printed first, then all microtasks (including the nested one) ran to completion, and only then did the macrotask execute. This is the exact event loop priority order.
16

Microtasks & queueMicrotask

Microtasks are a special queue that runs immediately after the current synchronous code, before any macrotask. Promise callbacks and queueMicrotask both schedule microtasks. queueMicrotask lets you explicitly schedule one without creating a Promise.

javascript
console.log("start");

queueMicrotask(() => console.log("microtask"));

setTimeout(() => console.log("macrotask"), 0);

console.log("end");
start and end are synchronous. queueMicrotask schedules a microtask. setTimeout schedules a macrotask. After sync code finishes, the microtask runs first, then the macrotask.
console
start end microtask macrotask
The microtask ran before the macrotask despite setTimeout having a 0ms delay. This proves microtasks always execute before macrotasks in the event loop.
17

Promise Combinators (all, allSettled, race, any)

Promise has four static combinators. Promise.all resolves when all succeed or rejects on the first error. Promise.allSettled waits for all and reports every outcome. Promise.race settles with the first to settle. Promise.any resolves with the first success.

javascript
const p1 = Promise.resolve(10);
const p2 = new Promise(res => setTimeout(() => res(20), 100));
const p3 = Promise.reject("fail");

Promise.all([p1, p2]).then(console.log);
Promise.allSettled([p1, p3]).then(console.log);
Promise.race([p1, p2]).then(console.log);
Promise.any([p3, p2]).then(console.log);
p1 resolves immediately, p2 resolves after 100ms, p3 rejects. Promise.all with p1 and p2 waits for both and logs [10, 20]. allSettled with p1 and p3 logs both outcomes including the rejection. race with p1 and p2 logs 10 (first settled). any with p3 and p2 ignores the rejection and logs 20 when p2 succeeds.
console
10 [10, 20] [{status:'fulfilled',value:10},{status:'rejected',reason:'fail'}] 20
Each combinator behaved differently: race returned the fastest (10), all returned both successes, allSettled reported the rejection instead of throwing, and any skipped the rejection to find the first success (20).
18

AbortController & Fetch Cancellation

AbortController lets you cancel an in-flight fetch request. You create a controller, pass its signal to fetch, and call controller.abort() to cancel. The fetch then rejects with an AbortError, which you can handle gracefully.

javascript
const controller = new AbortController();

setTimeout(() => controller.abort(), 100);

fetch("https://jsonplaceholder.typicode.com/todos/1", {
  signal: controller.signal
})
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === "AbortError") {
      console.log("Request cancelled");
    }
  });
An AbortController is created. setTimeout calls abort() after 100ms, cancelling the request. fetch receives the controller's signal. If the request is still in flight when aborted, the promise rejects with an AbortError, and the catch block prints "Request cancelled".
console
Request cancelled
The request did not complete before abort() fired, so fetch rejected with an AbortError. The catch block recognized err.name === "AbortError" and printed the cancellation message instead of treating it as a real failure.
19

Web Workers

Web Workers run JavaScript in a separate thread, keeping the main thread responsive. They communicate with the main thread using postMessage and onmessage. Workers are ideal for heavy computations that would otherwise freeze the UI.

javascript
// main.js
const worker = new Worker("worker.js");

worker.postMessage({ type: "sum", n: 1000000 });

worker.onmessage = (e) => {
  console.log("Result:", e.data);
};

// worker.js
self.onmessage = (e) => {
  let sum = 0;
  for (let i = 1; i <= e.data.n; i++) {
    sum += i;
  }
  self.postMessage(sum);
};
The main thread creates a Worker from worker.js and sends a message with n = 1000000. The worker receives it via self.onmessage, computes the sum in its own thread, and posts the result back. The main thread's onmessage receives and logs it.
console
Result: 500000500000
The sum of numbers 1 to 1,000,000 is 500,000,500,000. The computation ran in the worker thread, so the main thread stayed responsive. The result was sent back via postMessage and printed.
20

Design Patterns (Module, Singleton, Observer, Factory)

Design patterns are reusable solutions to common problems. The Module pattern uses closures for encapsulation. Singleton ensures one instance. Observer lets objects subscribe to events. Factory centralizes object creation.

javascript
const singleton = (() => {
  let instance;

  function create() {
    return { name: "Only One" };
  }

  return {
    getInstance() {
      if (!instance) instance = create();
      return instance;
    }
  };
})();

const a = singleton.getInstance();
const b = singleton.getInstance();
console.log(a === b, a.name);
An IIFE creates a closure holding an 'instance' variable. getInstance() creates the object only once and reuses it on later calls. Calling getInstance() twice returns the same object, stored in a and b.
console
true Only One
a === b is true because both variables point to the same single instance. This is the Singleton pattern: one object shared across the entire application.
21

Recursion & Performance

Recursion is when a function calls itself. It is elegant for problems like factorials and tree traversal, but deep recursion can hit the call stack limit. Tail recursion and iterative alternatives can reduce stack pressure.

javascript
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

console.log(factorial(5));
factorial(5) calls factorial(4), which calls factorial(3), and so on until factorial(1) returns 1. Each call multiplies its n by the result of the smaller call, building the answer back up the stack.
console
120
5 × 4 × 3 × 2 × 1 = 120. The recursive calls unwound and multiplied the values. This shows the base case (n <= 1) stopping the recursion before it goes infinitely deep.
22

Currying & Partial Application

Currying transforms a function with multiple arguments into a chain of single-argument functions. Partial application fixes some arguments upfront and returns a function expecting the rest. Both create specialized functions from generic ones.

javascript
function multiply(a) {
  return function(b) {
    return function(c) {
      return a * b * c;
    };
  };
}

const double = multiply(2);
const doubleThenTriple = double(3);

console.log(doubleThenTriple(4));
multiply is curried: each call returns another function until all three arguments are collected. multiply(2) fixes a=2, producing double. double(3) fixes b=3, producing doubleThenTriple. doubleThenTriple(4) supplies c=4 and computes the result.
console
24
2 × 3 × 4 = 24. The curried chain collected the arguments one at a time and only computed the final value once all three were supplied. This is the essence of currying.
23

Memoization

Memoization caches the results of expensive function calls keyed by their arguments. When the same inputs occur again, the cached result is returned instantly instead of recomputing. This is a major performance optimization for pure functions.

javascript
function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      console.log("from cache");
      return cache.get(key);
    }
    const result = fn(...args);
    cache.set(key, result);
    console.log("computed");
    return result;
  };
}

function slowAdd(a, b) {
  return a + b;
}

const fastAdd = memoize(slowAdd);
fastAdd(2, 3);
fastAdd(2, 3);
memoize wraps a function with a cache Map. The key is the JSON of all arguments. On the first call with (2,3), it computes and stores the result. On the second identical call, it finds the cached key and returns it without recomputing.
console
computed from cache
The first call printed "computed" because it ran slowAdd and cached the result. The second call printed "from cache" because the same arguments were already stored. This is memoization in action.
24

RegExp Deep Dive

Regular expressions are patterns for matching text. The RegExp object and regex literals let you test, search, and replace strings. Common flags are g (global), i (case-insensitive), and m (multiline). Character classes and quantifiers build powerful patterns.

javascript
const email = "[email protected]";
const pattern = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;

console.log(pattern.test(email));
console.log(email.match(/[0-9]+/g));
The pattern matches a simple email shape: username, @, domain, dot, and a 2+ letter TLD, with the i flag for case-insensitivity. test() checks if email matches (true). match(/[0-9]+/g) searches for digit sequences and returns them.
console
true null
The email passed the regex test because its shape matches. The match for digits returned null because the email contains no numbers. This demonstrates test and match with a practical pattern.
25

Advanced Recap + Capstone

You now know execution contexts, scope chains, this binding, prototypal inheritance, classes, private fields, symbols, iterators, generators, proxies, Reflect, WeakMap/WeakSet, memory management, the event loop, microtasks, promise combinators, AbortController, Web Workers, design patterns, recursion, currying, memoization, and regex. Apply them in a capstone that combines several advanced features.

javascript
class Store {
  #data = {};

  constructor() {
    return new Proxy(this, {
      get(target, prop) {
        if (prop in target.#data) return target.#data[prop];
        return target[prop];
      },
      set(target, prop, value) {
        if (prop === "data") return false;
        target.#data[prop] = value;
        return true;
      }
    });
  }

  all() {
    return { ...this.#data };
  }
}

const store = new Store();
store.name = "Ali";
console.log(store.name);
console.log(store.all());
Store uses a private #data object and returns a Proxy. The get trap reads from #data for data keys. The set trap stores new values into #data. store.name = "Ali" triggers set, and store.name triggers get. all() returns a shallow copy of the data.
console
Ali { name: 'Ali' }
The proxy stored 'Ali' into the private data and retrieved it back. all() returned the full data object. This capstone combined private fields, proxies, and class syntax into one cohesive component.

JavaScript Advanced Projects

Apply debouncing, cancellation, and the IntersectionObserver API. Click each project to open its full guide.

About this project

Build a search box that calls an API as the user types — but only after they stop typing (debounce), and it cancels stale requests when a new search starts (AbortController).

What you'll practice
debounce() AbortController fetch() async / await promises
Step-by-step: How it works
  1. The user types into the search input.
  2. A debounced function waits 300ms of inactivity before running.
  3. Each new search creates an AbortController.
  4. Before starting a new fetch, the previous request is aborted.
  5. The latest successful response is printed.
  6. Aborted requests are silently ignored, not treated as errors.
javascript
function debounce(fn, delay = 300) {
    let timer;
    let controller;

    return function(query) {
        clearTimeout(timer);
        if (controller) controller.abort();

        timer = setTimeout(async () => {
            controller = new AbortController();
            try {
                const response = await fetch(
                    `https://api.example.com/search?q=${query}`,
                    { signal: controller.signal }
                );
                const data = await response.json();
                console.log("Results:", data);
            } catch (err) {
                if (err.name === "AbortError") {
                    console.log("Cancelled old search");
                } else {
                    console.log("Error:", err.message);
                }
            }
        }, delay);
    };
}

const search = debounce(handleSearch);

search("ja");
search("jav");
search("java");
Sample Run
console
Cancelled old search Cancelled old search Results: [{ id: 1, name: "java" }, { id: 2, name: "javascript" }]

About this project

Load more content automatically when the user scrolls to a sentinel element. IntersectionObserver watches the sentinel and triggers a fetch for the next page when it becomes visible.

What you'll practice
IntersectionObserver async / await DOM manipulation fetch() closures
Step-by-step: How it works
  1. A sentinel div is placed at the bottom of the list.
  2. IntersectionObserver watches that sentinel.
  3. When the sentinel becomes visible, the next page is fetched.
  4. New items are appended to the list.
  5. The observer keeps watching until no more data remains.
javascript
let page = 1;
let loading = false;
const sentinel = document.getElementById("sentinel");
const list = document.getElementById("list");

const observer = new IntersectionObserver(async (entries) => {
    const entry = entries[0];

    if (entry.isIntersecting && !loading) {
        loading = true;

        const response = await fetch(`/api/posts?page=${page}`);
        const posts = await response.json();

        if (posts.length === 0) {
            observer.disconnect();
        } else {
            posts.forEach(post => {
                const div = document.createElement("div");
                div.textContent = post.title;
                list.appendChild(div);
            });
            page++;
        }

        loading = false;
    }
});

observer.observe(sentinel);
Sample Run
console
Page 1 loaded Page 2 loaded Page 3 loaded No more posts — observer stopped

Advanced Capstone Project

Combine proxies, private fields, localStorage, and reactive patterns into one complete project.

About this project

Build a reactive store that persists to localStorage and notifies subscribers whenever data changes. It uses a Proxy to intercept writes, a private field to hold state, and a simple pub/sub system.

What you'll practice
Proxy private fields localStorage pub/sub pattern JSON
Step-by-step: How it works
  1. The Store class keeps state in a private #data object.
  2. A Proxy intercepts property writes.
  3. Each write updates #data, persists to localStorage, and notifies subscribers.
  4. Subscribers register with subscribe() and receive the key and new value on every change.
  5. The store loads any saved state from localStorage in its constructor.
javascript
class Store {
    #data = {};
    #subscribers = [];

    constructor(key = "store") {
        this.key = key;
        const saved = localStorage.getItem(key);
        if (saved) this.#data = JSON.parse(saved);

        return new Proxy(this, {
            set(target, prop, value) {
                if (prop === "key" || prop === "data") return false;
                target.#data[prop] = value;
                target.#save();
                target.#notify(prop, value);
                return true;
            },
            get(target, prop) {
                if (prop in target.#data) return target.#data[prop];
                return target[prop];
            }
        });
    }

    #save() {
        localStorage.setItem(this.key, JSON.stringify(this.#data));
    }

    #notify(key, value) {
        this.#subscribers.forEach(fn => fn(key, value));
    }

    subscribe(fn) {
        this.#subscribers.push(fn);
    }

    all() {
        return { ...this.#data };
    }
}

const store = new Store("app-state");
store.subscribe((key, value) => {
    console.log(`Changed: ${key} = ${value}`);
});

store.theme = "dark";
store.language = "en";
console.log(store.all());
Sample Run
console
Changed: theme = dark Changed: language = en { theme: 'dark', language: 'en' }
You've completed all 25 lessons. Ready to continue?

Reinforce everything with JavaScript Practice Resources — exercises and quizzes.

📱 Scan this QR code with your phone camera to instantly open this page.

Works on iOS, Android, and any modern device. No app installation required.

Account Verified!

Your email has been verified successfully.