Skip to main content

Dim: Async State Management

· 6 min read
xoron
positive-intentions

I'm working on creating something I can call "functional web components".

Following the previous article explaining how we can create functional web components, we have the basics to put together an app. State management in such an approach is typically top-down to make the rendering predictable. However, I wanted to explore if there are any benefits to define and manage state in web components with a bottom-up approach. I wanted to see if it could give me a greater flexibility in developing an UI and not having to worry about the state management.

I wanted to create a hook that would work something like this:

const {
form: {
input: [inputValue, setInputValue],
},
todos: [todos, setTodos],
} = useStore({
form: {
input: useState(""),
},
todos: useState([]),
});

The function input could be seen as kind of a schema for the state and the return value would the the same object shape with the value ansd setter like you would have in React hooks. if any component would use this store with this input shape, it would be able to subscribe-to and set changes.

I guess this is against the principles of functional programming, but i decided to try with event listeners and custom events. This way if a setState is called, it can trigger a custom event. All other components that are listening to this event can update their state.


Creating the useStore hook

The useStore hook is a function above simply returns an object with the state and the setter from the useState hooks in the form and todos properties.


export const useStore = (store) => {
return store;
};

This function is quite simple. The useStore function is a simple function that returns the store object. The store object is passed as an argument to the useStore function. The store object is a simple object with the form and todos properties. The form property is an object with the input property that is an array with the value and setter from the useState hook. The todos property is an array with the value and setter from the useState hook.


Creating the AsyncronousStateManager

We inspect the shape of the store object to create identifiers for the properties we want to listen to. We can then create a custom event listener for each property. we would need some kind of storage manager to handle the state and the listeners.


class AsyncronousStateManager {
private store: any = {};
private eventListener: any[] = [];

generateListener(listener) {
const { listenerId, key, value } = listener;

// Remove existing event listeners if they exist
const existingListender = this.eventListener.find(
(listener) => listener.listenerId === listenerId
);

const listenerName = `${key}`;

if (existingListender) {
window.removeEventListener(listenerName, existingListender.listener);

this.eventListener = this.eventListener.filter(
(listener) => listener.listenerId !== listenerId
);
}

// Create a new event listener
const newListener = (event) => {
value[1](event.detail);

this.store = {
...this.store,
[key]: event.detail,
};
};

window.addEventListener(listenerName, newListener);

this.eventListener.push({
listenerId,
listener: newListener,
});

const newSetter = (newValue) => {
window.dispatchEvent(
new CustomEvent(listenerName, {
detail: newValue,
})
);
};

const newState = this.store[key] || value[0];

return [newState, newSetter];
}

removeListeners(listenerId) {
const existingListender = this.eventListener.find(
(listener) => listener.listenerId === listenerId
);

if (existingListender) {
window.removeEventListener(
`${existingListender.key}`,
existingListender.listener
);

this.eventListener = this.eventListener.filter(
(listener) => listener.listenerId !== listenerId
);
}
}
}

const asyncronousStateManager = new AsyncronousStateManager();

Lets break this down.

  • The AsyncronousStateManager class is a class that has a store property that is an object that holds the state. The eventListener property is an array that holds the event listeners.
  • The generateListener method is a method that generates a new event listener. The method takes a listener object as an argument. The listener object has a listenerId, key, and value properties. The listenerId property is a unique identifier for the listener. The key property is the key of the state in the store object. The value property is an array with the value and setter from the useState hook.
  • The generateListener method first removes existing event listeners if they exist, then creates a new event listener. this is so it always uses the latest state. The new event listener updates the state in the store object and calls the setter from the useState hook.
  • The removeListeners method is a method that removes event listeners. The method takes a listenerId as an argument then removes the event listener with the given listenerId from the eventListener array.

Creating the createListeners function

Now that we have a way to create and remove event listeners, we can now use it to event listeners that trigger state updates. lets create a function to traverse the store object and create event listeners for each property.

const createListeners = (store, listenerId) => {
const traverse = (obj, path) => {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "object" && obj[key].length === undefined) {
traverse(obj[key], `${path}${key}.`);
} else {
const [asyncState, asyncSetState] =
asyncronousStateManager.generateListener({
listenerId,
key: `${path}${key}`,
value: obj[key],
});

obj[key] = [asyncState, asyncSetState].concat(obj[key]);
}
});
};

traverse(store, "");
};

The createListeners function is a function that creates event listeners for each property in the store object. The function takes the store object and a listenerId as arguments. The function uses a traverse function to traverse the store object and create event listeners for each property.


Putting it all together

Now lets update our useStore hook to put it all together

export const useStore = (store) => {
const [randomId] = useState(crypto.getRandomValues(new Uint8Array(8)));

createListeners(store, randomId);

useEffect(() => {
return () => {
asyncronousStateManager.removeListeners(randomId);
};
}, []);

return store;
};


Demo


Conclusion

I dont recomend using this approach to create any production ready applications. This is more of an experiment to see if we can create asynchrounous state management for web components. Im sure there are more changes that can be made to make it better. I would like to hear your thoughts on this approach and if you think it could be improved in any way.