Intro
Easing into the subject
I think it took about 6 months before I got comfortable with 'using Redux'
. 16 months in and I've yet to become comfortable with 'Redux itself'
. Personally, I do realize why Redux is needed in large apps with scalability requirements, and for that matter - redux is a pure god-send. However for the majority of smaller apps, redux's cons could outweigh the pros depending on the circumstances
- Actions are rarely reused
- Being forced to separate logic
- What Dan says
What about Context API and other state management libraries?
As with every other package, depending on the project at hand, there could be alternatives that better suit your needs. But why not try making your own? So I started digging into the matter - what's the easiest way to create a global state management library?(Yes there is a lot of reasons to not try making your own but bear with me)
TLDR - the results
If you'd rather read the source code (npm package)
Ok, but why would I make one myself?
- What better way to show interest in a subject than say 'I tried making one myself, here are the results'. Possibly the best interview question answer.(Obviously after a lengthly description about various state management libraries and your experiences)
- Demystifying the possibly vague concept and mechanisms of global state management.
- With an understanding of how to start off, customizing for your project might take less time in setting up than actually easing into other global state management like redux which have quite the learning curve.
- Honestly there's not much reason, I just though I'd share my experience in the form of a tutorial. Learning redux(if you already haven't) is far more beneficial for most people and large scale app scenarios.
Why proxies and events instead of useState and hooks
So before I begun tackling the matter, I wanted to avoid making anything from React mandatory for the following reasons
- To make React optional(obviously)
- Finer controls over the store
- Most importantly, make the store updatable without having to drill update functions from a React component.
Personally I was fed up with having to drill store dispatchers through multiple functions since I had begun to move onto a more javascript focused coding style. My first attempt was by using rxjs's observers and observables to make this possible. It worked, but the rxjs dependency felt heavy for sites that needed minimal bundle size. So after a fair bit of researching, proxies paired with events felt like the perfect choice.
Proxies
The closest thing that mimics c++ operator overloading in js
would be my first impression.
But in reality it's a wrapper that allows you to define custom functionality for otherwise un-editable functions. Pair it with Reflect, and you can keep normal functionality and just have side effects.(This is a personal opinion and can be disputable - if so, let me know in the comments)
const store = {};
const storeProxy = new Proxy(store, {
set: function (obj, prop, value) {
obj[prop] = value;
// my custom set logic
//....
console.log(`I'm setting ${prop} to - `, value);
return true;
},
get: function (target, prop, receiver) {
const obj = Reflect.get(...arguments);
// my custom get logic
//...
return obj;
}
});
Now if you edit the store using the storeProxy like this
storeProxy.foo = "bar";
You'll see the custom set logic being executed. Kind of like an observer observing an observable!
On a sidenote, try creating an array with about 10 values, create a proxy that counts set operations, then pop a value and shift a value. You'll see why shifting values take O(n) time while popping take O(1) quite visually.
EventEmitter
Using CustomEvents and dispatching to the DOM works as well when using pure React. However in scenarios where the DOM is inaccessible (SSR or SSG using Nextjs for example), that could not be an option. Also events from event emitters have less dead-weight since they do not propagate or bubble anywhere.
Walkthrough
I eventually refactored my codebase to a Class based approach, but we'll do a functional approach for the sake of a wider audience.
Disclaimer I did not try out any of this code and there could be mistakes. Any form of constructive criticism is appreciated. The code below should serve as a guideline but could also work as intended. No promises :). The github repo in the TLDR section is working code.
Step 1 - The building blocks
// because using document events doesn't work on SSG / SSR
const Emitter = require("events")
const EventEmitter = new Emitter()
// virtually no limit for listeners
EventEmitter.setMaxListeners(Number.MAX_SAFE_INTEGER)
let eventKey = 0
export const createStore = (initObj) => {
// underbar for private methods / vars
const _evName = `default-${eventKey++}`
const _store = cloneDeep(initObj) // preferred deep cloning package, recommend rfdc
const _storeProxy = new Proxy(store, {
set: function (obj, prop, value) {
// apply options, restrictions pertaining to your needs
}
});
// dispatch logic to use when store is updated
const _dispatchEvent = () => {
EventEmitter.emit(_evName)
}
// ... the HOC and update logic
}
So this is the barebones version. Bear with me.
Underbars are in front of all declarations to simulate private declarations that won't be exposed outside.
_evName is defined so that events can be distinguished among multiple stores
Step 2 - The HOC and update logic
// ... the HOC and update logic
const updateStore = obj => {
// only update store when obj has properties
if(Object.getOwnPropertyNames(obj).length < 1) return;
// update logic via storeProxy
Object.getOwnPropertyNames(obj).forEach(key => {
// possible custom logic
_storeProxy[key] = obj[key];
});
// dispatch for EventEmitter
_dispatchEvent();
}
const getStore = () => return {..._store};
const createUseStore = () => {
// purely for rerendering purposes
const [dummy, setDummy] = useState(false);
const rerender = useCallback(() => setDummy(v => !v), [setDummy]);
useEffect(() => {
const eventHandler = () => rerender();
EventEmitter.on(_evName, eventHandler);
return () => EventEmitter.removeListener(_evName, eventHandler);
}, [rerender]);
// only updates when the above event emitter is called
return useMemo(() => {
return [this._store, this.updateStore];
}, [dummy]);
}
return [createUseStore, updateStore, getStore];
}
The actual update logic and the HOC are suddenly introduced and step 1 starts to make sense. The code is possibly simple enough to understand as it is, but here's how the logic goes.
- An event emitter is defined(globally)
- A store in the form of a js object is created
- A proxy is created that proxies the store with custom logic.
- updateStore is defined that sets the value for each key to the proxy, then dispatches the event
- getStore is defined that returns the current store deep-cloned.
- A HOC is defined that returns the store and update function.
Step 2.5 - Step 2 MVP in action
import {createStore} from "where/you/put/your/createStore";
const initMyStore = {
foo: bar
};
const [createUseMyStore, updateMyStore, getMyStore] = createStore(initMyStore);
const useMyStore = createUseMyStore();
export { useMyStore, updateMyStore, getMyStore };
import * as React from "react";
import {useMyStore} from "the/initcode/above";
export default function MyComponent() {
const [store] = useMyStore();
return (
<div>{store?.foo}</div>
)
}
// in another file far far away.....
import {updateStore} from "the/initcode/above";
function aFunctionNestedInside50Functions () {
updateStore({foo: "barbar"});
}
As stated above this is a barebones MVP, meaning that a LOT of core functionality that are usually expected for a global state management package are currently stripped away such as
- selective event dispatching
- selective property watching
- immutability or selective immutability
- Container predictability
- A LOT of safeguards that other global state management packages supply by default.
For the majority of simple apps, the above code + returning a deep copied / deep frozen version on 'get' should be enough.
Let's try expanding functionality to allow selective state updates and event dispatches
Step 3 - Functionality expanding
//...
// dispatch logic to use when store is updated
// updated keys are emitted to event emitter
const _dispatchEvent = (keys) => {
EventEmitter.emit(_evName, keys)
}
// ... the HOC and update logic
const updateStore = obj => {
// only update store when obj has properties
if(Object.getOwnPropertyNames(obj).length < 1) return;
// keys are stored to pass to dispatchEvent
let keys = [];
// update logic via storeProxy
Object.getOwnPropertyNames(obj).forEach(key => {
// possible custom logic
_storeProxy[key] = obj[key];
keys.push(key);
});
if(keys.length < 1) return;
// dispatch for EventEmitter
_dispatchEvent(keys);
}
const getStore = () => return {..._store};
// watch - which key of the store to watch
const createUseStore = (watch) => {
// purely for rerendering purposes
const [dummy, setDummy] = useState(false);
const rerender = useCallback(() => setDummy(v => !v), [setDummy]);
useEffect(() => {
const eventHandler = keys => {
// Don't rerender if property to watch are not part of the update keys
if(watch && !keys.includes(watch)) return;
rerender();
}
EventEmitter.on(_evName, eventHandler);
return () => EventEmitter.removeListener(_evName, eventHandler);
}, [rerender, watch]);
// only updates when the above event emitter is called
return useMemo(() => {
// return watched property when watch is defined.
if(watch) return [this._store[watch], this,updateStore];
return [this._store, this.updateStore];
}, [dummy, watch]);
}
return [createUseStore, updateStore, getStore];
}
A lot is going on here, but all for the functionality to be able to only have state updates when the 'watched' property is updated. For instance if the store was initialized like
{
foo: "bar",
fee: "fi",
fo: "fum",
}
and a component was like
export default function myComp () {
const [foo, updateStore] = useMyStore("foo");
return <>{foo}</>
}
This component will not be updated by
updateStore({fee: "newFi", fo: "newFum"});
but only when 'foo' is updated, which is one of the main functionalities that I wished to implement when I set out on this bizarre journey.
A lot more functionality with a class based approach is done in the github repo mentioned above so check it out if you're interested.
Conclusion
I don't know about you, but when I started to create my own version of a personalized state management library, creating new functionality for my global state was simply enjoyable - something I rarely experienced while fiddling around with redux, possibly yak shaving my time away. But jokes aside, for most use cases doing this is the pure definition of 'reinventing the wheel', so please implement and try out at your own discretion - a fun side project without heavy reliance on global state is a scenario I would personally recommend.
Top comments (1)
Thank you for this article! It clarified a lot for me how global stores might work internally.
The only thing I don't understand is the
getStore()
method - it doesn't return the proxied store, but the original one. Probably should return the proxied one instead, otherwise theget
method of our proxy will not be called when store property is accessed bygetStore()
. But yeah, as you said, it's a simple code that hasn't been tested and only serves as a bare example here. Good job!