Reactive Local Storage
A study on how to make a truly reactive local store hook.
Please do not turn this into a library.
Heads up, this solution ends up using an iframe with
This repository contains the code studied on this article.
Sharing state in a React application is often done through lifting state and then prop drilling, composition, context or through an external state manager.
In some cases, not only do we need to share state, but we want to persist it across user sessions. The Storage API is used for this matter, most commonly the
A naive implementation might look like the snippet above. Clearing the key if it changes during its current use, and always setting a new value when state changes.
When write to the
localStorage, we serialize our data. When we write to it, we deserialize it.
It is important to be aware of this, because naively, the serialization and deserialization process resolve to new references. So even though the source strings might be the same, we end up with different objects.
Of course one could optimize this away, by keeping a look-up table of strings and references, but there's always a trade-off going on, because now we consume memory storing such a table.
Could we possibly use the
localStorage API, to avoid having to lift state, prop drilling or context?
Or in other words, if we have two or more instances of
useLocalStorage pointing to the same key, can we keep them in sync?
If we created
And then instantiate it in different parts of the application, we'll observe that those two are out of sync, even worse, one overrides the other, and unless rendering happens, we don't, update the UI.
We could initially say that, within our application, all communication to the local storage must be made through a set of functions that we are in control of.
It is a stretch, but client side routing works in similar fashion. If you don't use the
Link provided by the client side router, and use a regular anchor tag instead, your entire application will offload, and the browser will navigate to the new route, whereas with the
Link the affected Components, unmounted or render as necessary.
At first this would work great, as long as all updates pass through our functions, we can keep the UI in sync.
Yet another problem
Third party libraries might update the store too. Perhaps this is a blessing in disguise, because poorly formed data, or just plain attempts at attacking our application will have to deal with the lack of reactivity.
The same goes for browser users. By simply opening the developer tools, users can modify the local storage. It may be possible to recover from changes made by users, but if we are actually doing business logic with a third party library that is making changes to some data in the local storage we are in trouble.
One more effort could be to poll on the local storage. Say every second, read to see if a key we care about, has changed.
I think, all things consider this is probably where one would stop going forward with a reactive local storage hook.
The storage event and cross domain storage
You might be thinking, but there's an event, called
storage fired when the local storage, and session storage change.
And yes, there's such an event, but the kicker is that it only fires, if the storage changes within the context of another document. For instance another tab loaded on the same domain.
This could be used by businesses or malicious players, to track people across domains, by using an iframe that loads a vendor domain.
Zendesk has an API called Cross-Storage that helps you do this. However, it has gotchas on Safari and other Apple products. Because these, for better or worse, sandbox storage on iframes to the top level domain using them, effectively breaking cross domain storage tracking.
The cross domain local storage uses
postMessage, and it is rather difficult to debug, if you ever try to, be ready to spend a lot of time setting things up, but it works! The rough plan here is to have a domain load your iframe, and have it send data via
postMessage to your iframe. Inside your iframe you listen to
message events, and upgrade the local storage for your domain accordingly. If there's yet another page loading your iframe you can listen to the
storage event, and update that second domain as you wish.
Reactive Local Storage
The key insight in all of this, is that an iframe contains its own document context, and if it's loaded by us, using a source such as
about:blank (which has a bunch of issues of its own, see this resource), we could attach an event listener on the iframe window, and let our application know that the local storage has changed.
In particular, we could configure our
useLocalStorage to update the state when such an event happens.
A couple of things here:
- Assume we have a reference to an iframe in the current document.
setState, actually points saves to the current window local storage.
- Attach a
storageevent listener to the iframe.
- When that event fires, update the React state of the hook.
There is also heavy serialization and deserialization going on, which might make an application way slower than it should be. It seems like a lot to pay for little gain. Even worse, if the state is actually the same, we'll end up creating a new reference anyway.
One way out, would be to bail out on setting state, by reading from the store right before returning from
setState, serialize the update, and compare both, if it's the same string, don't do anything to the local storage.
This API, doesn't support passing a function to update state, but it could! In fact, I deleted it for this demo.
In the above snippet, to avoid leaking the iframe to the hook, I have an object called
storage, which exposes the methods I care about. This is how
storage is made:
setupReactiveStorage function creates an iframe, which
initReactiveStorage appends to the current document.
And yet another problem
The usage of
After Chrome and Brave, I jumped into Firefox, and there between two tabs the counters fell out of sync.
On a setup with two tabs running the same application, Brave doesn't seem to let the updates happen on the background tab, unless you focus onto it.
The Firefox issue was really strange, because things worked fine for a little moment, and then one of the tabs just became unresponsive to counter changes. However, using
There's even an ESLint rule against
This was an experiment with React and the local storage, and how far could we bend the rules of storage. It'd discourage anyone from trying to abstract this into a
npm package or use it in production.
There are certain risks on the fact that this effectively creates a direct path into your React state, from the local storage API. Such a risk is always present when working the local storage, or taking any user input for that matter, but the
useReactiveLocalStorage from this study makes that particularly more aggressive.