The Core of Reactivity: 4 Native JavaScript State Management Patterns
In modern web development, the core challenge is keeping the UI in sync with the application's data. We call this "state management." When your data (the "state") changes, you want the DOM (the "view") to update automatically.
While many frameworks exist to solve this, the underlying patterns are built on powerful, native JavaScript features. Understanding these patterns not only makes you a better engineer but allows you to build highly efficient, lightweight, and dependency-free solutions for any project.
Let's explore four foundational patterns for native state management.
1. The Classic: Getters & Setters (Object.defineProperty)
This is the original, time-tested method for intercepting property access. You create an object where you define custom "getter" and "setter" functions for a specific property. The set function is where you trigger your reaction.
How it works: You use Object.defineProperty() to define a property on an object. When you try to assign a new value to that property, it triggers your custom setter function, which can then run any code you want—like updating the DOM.
Code Example:
// Our state object
const state = {
_price: 10, // A "private" internal value
};
// The function that updates our UI
function updatePriceDisplay(newValue) {
// A generic updater, e.g., finds an element and sets textContent
console.log(`Updating DOM with new price: ${newValue}`);
document.getElementById('price-display').textContent = newValue;
}
// 1. Define the 'price' property on our state object
Object.defineProperty(state, 'price', {
enumerable: true, // Lets it be seen in for...in loops
// 2. The GETTER: simply returns the internal value
get() {
return state._price;
},
// 3. The SETTER: triggers our UI update "reaction"
set(newValue) {
if (newValue !== state._price) {
state._price = newValue;
// This is the "reaction"
updatePriceDisplay(newValue);
}
}
});
// Now, when you run this...
state.price = 20; // ...the setter automatically fires!
Pros & Cons:
- Pro: Extremely robust browser support (IE9+).
- Pro: Simple to understand for single-property observation.
- Con: Verbose. You must define this for every single property you want to watch.
- Con: It doesn't scale well. It doesn't work for new properties added to the object later or for properties that are deleted.
2. The Modern Powerhouse: Proxy
This is the modern, far more powerful approach. A Proxy is an object that wraps another object and allows you to intercept fundamental operations (like getting, setting, or deleting properties) using a handler.
How it works: You create one Proxy around your entire state object. The handler (its configuration) has a set trap that intercepts all property assignments, no matter the property name. This is the engine behind modern frameworks like Vue 3.
Code Example:
// A generic UI update function
function updateDisplay(property, newValue) {
const el = document.getElementById(`${property}-display`);
if (el) {
el.textContent = newValue;
}
}
// 1. The original, plain JavaScript object
const state = {
price: 10,
itemName: 'Basic T-Shirt'
};
// 2. The handler with the 'set' trap
const stateHandler = {
set(target, property, value) {
// target = the original 'state' object
// property = the key being set (e.g., 'price')
// value = the new value (e.g., 25)
// Update the original object
target[property] = value;
// This is the "reaction" - it's generic for ANY property!
updateDisplay(property, value);
// A setter trap must return true
return true;
}
};
// 3. Create the reactive proxy
const stateProxy = new Proxy(state, stateHandler);
// You now ONLY interact with the proxy:
stateProxy.price = 25; // Triggers the trap!
stateProxy.itemName = 'Cool Hat'; // This also triggers the same trap!
Pros & Cons:
-
Pro: Extremely powerful and clean. It automatically handles all properties, including new ones you add later (
stateProxy.newProp = 'hi') or array methods. - Pro: Your original state object remains a clean, plain object.
-
Pro: You can intercept many other operations (e.g.,
get,deleteProperty). - Con: Modern browser support (no IE). This is generally a non-issue in 2025.
3. The Scalable Architect: The Pub/Sub (Observer) Pattern
This is a foundational computer science pattern, also known as Publish/Subscribe or Observer. It's less about "magic" and more about creating an explicit, scalable system.
How it works:
- You create a central "Store" object.
- Other parts of your code ("subscribers" or "observers") register themselves with the store, providing a callback function.
- When you want to change the state, you must call a specific method on the store (e.g.,
store.setState()). - The store updates its internal state and then "publishes" the change by looping through all its subscribers and executing their callback functions with the new state.
Code Example:
// 1. Create the store
const stateStore = {
state: {
price: 10
},
subscribers: [], // A list of callback functions
// 2. A method for other parts of the app to "subscribe"
subscribe(callback) {
this.subscribers.push(callback);
},
// 4. The internal "publish" method
publish() {
// Tell all subscribers about the change
this.subscribers.forEach(callback => callback(this.state));
},
// 3. A dedicated method to update the state
setState(newState) {
this.state = { ...this.state, ...newState };
// 4. Publish the change!
this.publish();
}
};
// --- In another part of your app (e.g., your UI) ---
// Have your UI "subscribe" to changes
stateStore.subscribe((newState) => {
document.getElementById('price-display').textContent = newState.price;
});
// Now, to make a change, you MUST use the method:
stateStore.setState({ price: 30 }); // This will trigger the subscriber!
Pros & Cons:
- Pro: Extremely scalable and organized. It forces a clear data flow, which is excellent for large applications. This is the foundation of libraries like Redux.
- Pro: Fully decoupled. The state store doesn't know or care what the subscribers are (they could be UI, loggers, or API calls).
- Con: More boilerplate. You have to write all the store logic yourself.
-
Con: It's not "magic." You can't just set a property (
store.state.price = 40); you must use thesetStatemethod to trigger the update.
4. The 'Powered-Up' State Factory (Signal + Proxy)
This is the most robust pattern. It uses a clean "Signal" factory (similar to Pub/Sub) but enhances it with a Proxy to solve the nested object problem.
How it works:
- The
createStatefunction holds a privatevalueand aSetofsubscribers. - The Enhancement: If the
initialValueis an object, it is immediately wrapped in aProxy. - This
Proxy'ssethandler is configured to call thenotifyfunction after any property is changed. - This means you get the clean API of
valueandvaluefor simple values, but you also get automatic reactivity when you mutate an object's properties directly.
The Complete Code:
function createState(initialValue) {
const subscribers = new Set();
function notify(newValue, oldValue) {
for (const callback of subscribers) {
callback(newValue, oldValue);
}
}
// --- PROXY ENHANCEMENT ---
// This handler will be used if the value is an object.
// It calls 'notify' on any property change.
const proxyHandler = {
set(target, property, newValue) {
const oldValue = { ...target }; // Shallow copy for comparison
const result = Reflect.set(target, property, newValue);
// Notify with the entire object
notify(target, oldValue);
return result;
}
};
// Store the internal value. If it's an object, make it a proxy.
let value = (typeof initialValue === 'object' && initialValue !== null)
? new Proxy(initialValue, proxyHandler)
: initialValue;
return {
get value() {
return value;
},
set value(newValue) {
const oldValue = value;
// If the new value is an object, it also needs to be proxied
// so it can be mutated directly later.
if (typeof newValue === 'object' && newValue !== null) {
value = new Proxy(newValue, proxyHandler);
} else {
value = newValue;
}
if (oldValue !== value) {
notify(value, oldValue);
}
},
subscribe(callback) {
subscribers.add(callback);
// Return an 'unsubscribe' function for easy cleanup
return () => subscribers.delete(callback);
}
};
}
// --- USAGE EXAMPLE ---
// 1. Example with a primitive (same as before)
const count = createState(0);
count.subscribe((newVal) => console.log(`Count: ${newVal}`));
count = 5; // Logs: "Count: 5"
// 2. Example with an object (now works as intended)
const user = createState({ firstName: "John", age: 18 });
user.subscribe((newUser) => {
console.log('User changed:', newUser);
});
// This mutation now triggers the proxy's 'set' trap:
user.value.age = 19;
// Logs: "User changed: { firstName: 'John', age: 19 }"
// You can still set the whole object, too:
user.value = { firstName: "Jane", age: 20 };
// Logs: "User changed: { firstName: 'Jane', age: 20 }"
Pros & Cons:
- Pro: The most powerful and clean solution.
- Pro: Handles both simple values and deep object mutations automatically.
- Pro: Extremely reusable, scalable, and testable.
-
Con: Relies on modern
ProxyandReflectAPIs (no IE support).
Conclusion: Which One Should You Use?
Choosing the right native pattern depends on your project's scale:
- Getters/Setters: Use this for simple, isolated cases where you need to observe one or two properties and require wide browser compatibility.
-
Proxy: This is the best modern choice for building self-contained reactive components (e.g., a "widget") with clean code. - Pub/Sub (Observer): Use this when you are building a larger application and need a single, explicit, reliable "source of truth" that many components must share.
- State Factory (Signal + Proxy): This is the most robust modern approach. It's clean, reusable, and handles any state management need, making it a perfect foundation for a lightweight custom library.
Top comments (0)