Background
Recently I'm working on a project generator. I think a blueprint to create a planet is a good analogy. The blueprint is how we want users to build this certain type of planet. And inside this blueprint, there are some core features which we want to share, some for consistency, some others for future exploration. So we extract them out and put them in a "headquarter". The headquarter then exports a few app layer things such that the planets created will look alike. It also provides some APIs to satisfy feature and customizability needs.
Problem
I've been bugged by the thoughts around some "very procedural API". What I mean by very procedural API is something that looks like this:
import { someOuterSpaceOperation } from 'headquarter';
const MyPlanet = () => (
<div onClick={() => someOuterSpaceOperation('hi headquarter')}>
called from my planet
</div>
);
Namely, we don't want the API to look like anything related with the component's life cycle. So no hooks, better no syntax sugars like connect
neither. I'm free to hide the logic somewhere, maybe by the boundary of a file import, after which the user of the API may spell the magic code someOuterSpaceOperation()
.
The but the action is not that outer space. In connects back to certain component inside headquarter
, and someOuterSpaceOperation
may result in a component update. And in the remainder of the universe, we want creatures on those planets to be able to call someOuterSpaceOperation()
without realizing we'll still on the nether of a same React app.
Solution
Honestly, I was very, very stuck with this because I felt my imagination was very limited by the React APIs and the existing libraries. I had some rough thoughts around creating and maintaining some kind of store on our own, maybe write some flexible JavaScript to subscribe to it. But I was unable to see how that should actually happen, or where exactly relevant code should go, until Jinjiang sent me an example demo, which I then developed to this codesandbox.
Rough idea: Create a store and have headquarter
subscribe to it. The setter side of the store, however, we export to the planets to consume directly.
So my tiny store will look like this. It maintains a store value, a setter and a getter function, and a way to subscribe to it. It has a couple of assumptions: the store value must be an object, and it allows only one listener. Both assumptions are satisfied in the use case with our headquarter
.
class Store extends Object {
constructor(initValue) {
super(initValue);
if (typeof initValue === 'object') {
this.value = initValue;
} else {
this.value = {};
}
this.listener = null;
}
get = () => this.value;
merge = newValue => {
this.value = { ...this.value, ...newValue };
if (typeof this.listener === 'function') {
this.listener(this.value);
}
};
subscribe = cb => {
if (typeof cb === 'function') {
this.listener = cb;
}
};
}
export default Store;
With this Store
class, we can create a store and export the very procedural API we desired, it is free to be called outside of a component's lifecycle,
import Store from './store';
export const outerspaceHQStore = new Store({ agent: 0 });
// setAgent can be called outside of a component's lifecycle
export const setAgent = agent => outerspaceHQStore.merge({ agent });
Now in our headquarter, subscribe to the store and put that store value in a stateful variable, then inside a context.
const AgentProvider = ({ children }) => {
// manage subscription here
// put in react component tree an internally maintained stateful variable
// that is subscribed to the newest val
const [val, setVal] = React.useState(outerspaceHQStore.get());
outerspaceHQStore.subscribe(newVal => setVal(newVal));
return <AgentContext.Provider value={val}>{children}</AgentContext.Provider>;
};
Here I used something I learned from Jamie's hook-based Unstated library, wrapping and re-exporting the context provider allows us to keep all the logic about this API in one place.
Then, the users of our very procedural API is able to call setAgent
anywhere they want, like this:
const ComponentThatSetsAgent = () => (
<button
onClick={() => {
setAgent(Math.ceil(Math.random() * 1000));
}}
>
call outerspace!
</button>
);
Then inside headquarter
, we're able to grab the variable subscribed to our store from its corresponding context:
const ComponentThatDisplaysAgent = () => {
// grabs the value from context
const { agent } = React.useContext(AgentContext);
return <h1>received call from agent {agent}</h1>;
};
Other thoughts
First time I do silly things with React, feels a bit hacky, and very unsure. Turns out, I'm home cooking state management with a connector, with a very naive subscription? Also, the contrast is stiff. I mean, I previously thought it was out of my imagination but it seems pretty obvious to me now.