DEV Community

joon
joon

Posted on

Make your own state management for React with Proxies and Event emitters

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

Whatever Dan Abramov says to do

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;
    }
});
Enter fullscreen mode Exit fullscreen mode

Now if you edit the store using the storeProxy like this

storeProxy.foo = "bar";
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode
import * as React from "react";
import {useMyStore} from "the/initcode/above";

export default function MyComponent() {
    const [store] = useMyStore();
    return (
        <div>{store?.foo}</div>
    )
}
Enter fullscreen mode Exit fullscreen mode
//  in another file far far away.....
import {updateStore} from "the/initcode/above";

function aFunctionNestedInside50Functions () {
    updateStore({foo: "barbar"});
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

and a component was like

export default function myComp () {
    const [foo, updateStore] = useMyStore("foo");
    return <>{foo}</>
}
Enter fullscreen mode Exit fullscreen mode

This component will not be updated by

updateStore({fee: "newFi", fo: "newFum"});
Enter fullscreen mode Exit fullscreen mode

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 (0)