Editor’s note: This article was last updated by Joe Attardi on 13 August 2024 to update terminology relating to JavaScript proxies/ES6 proxies, as well as to cover additional use cases for proxies, such as trace logging, intercepting Fetch requests, and more.
Metaprogramming is a powerful technique that enables you to write programs that can create other programs. ECMAScript 2015 (ES2015), formerly known as ES6, made it easier to utilize metaprogramming in JavaScript with the help of proxies and other similar features. Proxies facilitate the redefinition of fundamental operations in an object, opening the door for a wide variety of possibilities.
In this guide, we’ll show you how to apply proxies in practical situations.
This tutorial is primarily aimed at developers who have experience with JavaScript and are at least familiar with the idea of proxies. If you have a firm understanding of proxies as a design pattern, that knowledge should translate.
After reading this guide, you should be able to:
Fundamentally, a proxy is something or someone that becomes something else’s substitute, so that whatever it is, it has to go through the substitute to reach the real deal.
To effectively implement and use a proxy, you must understand three key terms:
Putting all this together, below is the simplest implementation in which you can return something different if a given property doesn’t exist in an object using a proxy:
const target = { someProp: 1 } const handler = { get: function(target, key) { return key in target ? target[key] : 'Doesn't exist!'; } } const proxy = new Proxy(target, handler); console.log(proxy.someProp) // 1 console.log(proxy.someOtherProp) // Doesn't exist!
A proxy is a powerful feature that facilitates the virtualization of objects in JavaScript.
You can use proxies to add logging to function calls. This can be useful for debugging or tracing. You can create a general-purpose function that wraps a given function in a proxy. The proxy will print a log statement any time the function is called, including its arguments.
To accomplish this, you can use a proxy’s apply
trap to catch function calls. This trap receives the function being called, the this
argument, if any, and an array of arguments:
function trace(originalFunction) { return new Proxy(originalFunction, { apply(target, thisArg, args) { console.log('Calling function:', target.name, 'with arguments:', args); // Important - don't forget the return statement or else the function's // return value is lost! return target.apply(thisArg, args); } }); }
Suppose you have a getUser
function that takes a user ID. You can create a tracing version of this function by passing it to the trace
helper function:
function initDebugLogging() { getUser = trace(getUser); } initDebugLogging();
The initDebugLogging
function will replace the getUser
function with a proxied version that will print each call, with its arguments, to the console whenever the function is called:
// prints: Calling function: getUser with arguments: [1] getUser(1); // prints: Calling function: getUser with arguments: [2] getUser(2);
Data binding is often difficult to achieve due to its complexity. The application of proxies to achieve two-way data binding can be seen among model-view-controller libraries in JavaScript, where an object is modified when the DOM undergoes a change.
To put it simply, data binding is a technique that binds multiple data sources together to synchronize them.
Suppose that there is an <input>
with an ID of username
:
<input type="text" id="username" />
Let’s say you want to keep the value of this input in sync with a property of an object:
const inputState = { id: 'username', value: '' }
It’s quite easy to modify the inputState
when the value of the input
changes by listening to the change
event of the input and then updating inputState
‘s value. However, the reverse — updating the input
when the inputState
is modified — is quite difficult.
A proxy can help in such a situation:
const input = document.querySelector('#username') const handler = { set: function(target, key, value) { if (target.id && key === 'username') { target[key] = value; document.querySelector(`#${target.id}`) .value = value; return true } return false } } const proxy = new Proxy(inputState, handler) proxy.value = 'John Doe' console.log(proxy.value, input.value) // 'John Doe' will be printed for both
This way, when the inputState
changes, the input
will reflect the change that has been made. Combined with listening to the change
event, this will produce a simple two-way data binding of the input
and inputState
.
While this is a valid use case, it’s generally not encouraged. More on that later.
Caching is an ancient concept that allows very complex and large applications to remain relatively performant. Caching is the process of storing certain pieces of data so they can be served much faster when requested. A cache doesn’t store any data permanently. Cache invalidation is the process of ensuring that the cache is fresh. This is a common struggle for developers. As Phil Karlton said, “There are only two hard things in computer science: cache invalidation and naming things.”
Proxies make caching easier. If you want to check whether something exists in an object, for example, you would first check the cache and return the data or do something else to obtain that data if it doesn’t exist.
Let’s say you need to make many API calls to obtain a specific piece of information and do something with it:
const getScoreboad = (player) => { fetch('some-api-url') .then((scoreboard) => { // do something with scoreboard }) }
This would mean that whenever the scoreboard of a player is required, a new call has to be made. Instead, you could cache the scoreboard when it is first requested, and subsequent requests could be taken from the cache:
const cache = { 'John': ['55', '99'] } const handler = { get: function(target, player) { if(target[player] { return target[player] } else { fetch('some-api-url') .then((scoreboard => { target[player] = scoreboard return scoreboard }) } } } const proxy = new Proxy(cache, handler) // access cache and do something with scoreboard
This way, an API call will only be made if the cache doesn’t contain the player’s scoreboard.
You can use a proxy to wrap an expensive function to create a memoized version of it. This new version of the function will use a cache to remember the results of a given list of arguments.
Here’s how to create a simple memoizer using a proxy:
function memoize(fn) { const cache = new Map(); return new Proxy(fn, { apply(target, thisArg, args) { const cacheKey = JSON.stringify(args); if (cache.has(cacheKey)) { return cache.get(cacheKey); } const result = target.apply(thisArg, args); cache.set(cacheKey, result); return result; } }); }
Consider a function that calculates the nth Fibonacci number:
function fibonacci(n) { if (n <= 1) { return n; } return fibonacci(n - 1) + fibonacci(n - 2); }
You can create a memoized Fibonacci function by calling memoize
:
const memoizedFibonacci = memoize(fibonacci);
You can call this new function just like you would call the original fibonacci
function. Now, the recursive Fibonacci calculation is only done once per argument — after that, it’s cached.
A few notes on this approach:
JSON.stringify
on the arguments array. This approach may not work for more complex functions, and in a real-world application, you’d probably need to do some smarter form of cachingThe simplest use case for proxies is access control. Most of what the proxy is known for falls under access control. The scenario we walked through to show how to implement proxies is an example of access control.
Let’s explore a few practical applications of access control using a proxy.
One of the most intuitive use cases for proxies is validating what comes inside your object to ensure that the data in your object is as accurate as possible. For example, if you want to enforce a maximum number of characters for a product description, you could do so like this:
const productDescs = {} const handler = { set: function(target, key, value) { if(value.length > 150) { value = value.substring(0, 150) } target[key] = value } } const proxy = new Proxy(productDescs, handler)
Now, even if you add a description that’s longer than 150 characters, it’ll be cut short and added.
There may come a time when you want to ensure that an object is not modified in any way and can only be used for reading purposes. JavaScript provides Object.freeze()
to do this, but the behavior is more customizable when using a proxy:
const importantData = { name: 'John Doe', age: 42 } const handler = { set: 'Read-Only', defineProperty: 'Read-Only', deleteProperty: 'Read-Only', preventExtensions: 'Read-Only', setPrototypeOf: 'Read-Only' } const proxy = new Proxy(importantData, handler)
Now when you try to mutate the object in any way, you’ll only receive a string saying Read Only
. Otherwise, you could throw an error to indicate that the object is read-only.
JavaScript doesn’t have private properties per se, except for closures. When the Symbol
data type was introduced, it was used to mimic private properties. But it fell by the wayside with the introduction of the Object.getOwnPropertySymbols
method. Proxies aren’t a perfect solution, but they do the job in a pinch.
A common convention is to identify a private property by prepending an underscore before its name. This convention enables you to use proxies:
const object = { _privateProp: 42 } const handler = { has: function(target, key) { return !(key.startsWith('_') && key in target) }, get: function(target, key, receiver) { return key in receiver ? target[key] : undefined } } const proxy = new Proxy(object, handler) proxy._privateProp // undefined
Adding the ownKeys
and deleteProperty
will bring this implementation closer to being a truly private property. Then again, you can still view a proxy object in the developer console. If your use case aligns with the above implementation, it’s still applicable.
Suppose you want to add a loading indicator or progress bar to your app, which uses the Fetch API, that animates any time an API request is in progress.
You can use a proxy to trigger this progress bar to start and stop as requests start and finish with the Fetch API. To make sure the animation runs as long as there are active requests, you can use a counter to keep track of how many active requests there are. When this count is greater than zero, the animation runs. When it reaches zero, the animation stops:
let requestCount = 0; window.fetch = new Proxy(window.fetch, { apply(target, thisArg, args) { // new request, increment the count requestCount += 1; if (requestCount === 1) { // start the animation } const result = target(...args); result.then(() => { // request finished successfully - decrement the count requestCount -= 1; }).catch(() => { // also need to decrement if a request fails requestCount -= 1; }).finally(() => { if (requestCount === 0) { // stop the animation } }); return result; } });
Note: Always be careful, and test thoroughly, if you are proxying to a global function like fetch
. A bug in your proxy handler could break your entire app! You should also perform testing to make sure the proxy does not negatively affect performance when using Fetch in your app.
Proxies are not ideal for performance-intensive tasks. That’s why it’s crucial to perform the necessary testing. A proxy can be used wherever an object is expected, and the complex functionality that proxies provide with just a few lines of code makes it an ideal feature for metaprogramming.
Proxies are typically used alongside another metaprogramming feature known as Reflect
.
Hopefully, this guide has helped you understand why JavaScript proxies are such a great tool, especially for metaprogramming. You should now know:
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowHandle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
3 Replies to "Practical use cases for JavaScript proxies"
There seems to be a typo at the first code snippet in “3. Private properties”. In “handler” definition instead of “target in key” should it read “key in target”?
You’re correct, Simon. The proper syntax is indeed ‘prop in object’, which you can confirm here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in
Good catch. 🙂
“While this is a valid use case, it’s generally not encouraged. More on that later.”
You didn’t really explain why it’s not encouraged, even later on.