Shared State Without React's Context
You are a React developer in 2020, and this pattern is too familiar.
You can probably type it with your eyes closed.
function App() {
return (
<CounterStateProvider>
<Counter />
<CounterIncButton />
</CounterStateProvider>
);
}
const CounterContext = React.createContext({ value: 0, incrementValue: () => {} });
function CounterStateProvider({ children }) {
const [ value, setValue ] = useState(0)
const incrementValue: () => {
setValue( value + 1 )
}
return (
<CounterContext.Provider value={{ value, incrementValue }>
{ children }
</CounterContext.Provider>
)
}
Two components in different parts of your application need to share the same state.
You don't want to pass values and callbacks via props all the way, so you're using React's Context.
In this simplified example, we have just two components: Counter
displays the current counter value, and CounterIncButton
increments that value by one.
What if I tell you that you can achieve the same behavior without introducing the ugly CounterStateProvider, and have it framework-agnostic? No extra libraries, plain JavaScript, and just under 30 lines of code.
Let's start from the end...
I would ask you to close your eyes now and imagine that we already built our shiny state manager.
How does it look like? Or, to put it correctly, what interface are we going to implement?
What we need is:
- (1) a way to read the current value,
- (2) a way to update it, and
- (3) to be able to react when the value is changed.
const counterStore = createStore(0);
// get and update the value
counterStore.getValue(); // => 0
counterStore.setValue(1);
counterStore.getValue(); // => 1
const clean = counterStore.onChange(() => {
// this function is invoked every time the value changes
// and it returns a function that remove the callback
});
We'll get to implementation soon, but first, let's get React out of the way.
Binding with your favorite framework
Assuming that our store is working, how can we "bind" the state to the component (by which I mean to re-render on change).
A great way to force a re-render is to simply call setState
.
// create store with a default value 0
const counterStore = createStore(0);
const Counter = () => {
const [value, setValue] = useState(counterStore.value());
useEffect(() => {
// add a change callback
const clean = counterStore.onChange((newVal) => {
// envoking setState to cause re-render
setState(newVal);
});
// don't forget to clean
return clean;
}, []);
return <div>{value}</div>;
};
Now that's a bit clumsy. All that code inside useEffect has nothing to do with the business logic and only clutters the view.
Thankfully we can abstract it away by introducing a hook.
// ==================
// THIS ONE YOU PROBABLY WOULD KEEP IN A DIFFERENT FILE
// ==================
const useSharedStore = (store) => {
const [value, setValue] = useState(counterStore.value());
useEffect(() => {
// add a change callback
const clean = counterStore.onChange((newVal) => {
// envoking setState to cause re-render
setState(newVal);
});
// don't forget to clean
return clean;
}, []);
return [ value, counterStore.setValue ]
}
// ===================
const counterStore = createStore(0);
const Counter = () => {
const [ val ] = useSharedStore(counterStore)
return <div>{value}</div>;
};
const CounterIncBtn = () => {
const [ val, setValue ] = useSharedStore(counterStore)
return <button onClick={() => setValue(val + 1)}>+1</button>;
};
š, right?
Note how we emulated the useState
interface. In the same fashion, our hook returns an array with two items, the first one being the value itself, and the second one is the setter.
The difference is that useState
creates a local state, while we have just created a shared state!
This little hook is what we need to connect our store with the React world. Otherwise, our little state manager is completely framework-agnostic.
Finally, with React out of the way, we can talk about the implementation...
The unnecessary implementation
It's unnecessary because we already asked the good question, which is half of the answer ā we know the interface.
To put it the other way, we proved that the problem has a solution.
Should we also actually solve it?
If you insist.
Let me remind that our store has nothing to do with React. Our state manager is entirely free from any framework-related assumptions.
What this means is that we'll be able to share the state not only between components of React tree but also between different frameworks.
As you recall, we need to implement a function that creates a new store and returns 3 functions:
function createStore(defaultValue) {
const getValue = () => {
// implement me
};
const setValue = () => {
// implement me
};
const onChange = () => {
// implement me
};
return {
getValue,
setValue,
onChange,
};
}
We can store a value right there in a closure.
function createStore(defaultValue) {
let value = defaultValue;
const getValue = () => {
return value;
};
const setValue = () => {
// implement me
};
const onChange = () => {
// implement me
};
return {
getValue,
setValue,
onChange,
};
}
setValue
should do 2 things: update the inner value, and also notify all subscribers that the value has been changed.
function createStore(defaultValue) {
let value = defaultValue;
const callbacks = [];
const getValue = () => {
return value;
};
const setValue = (newValue) => {
value = newValue;
callbacks.forEach((cb) => cb(newValue));
};
const onChange = () => {
// implement me
};
return {
getValue,
setValue,
onChange,
};
}
Finally, onChange
should add the passed callback, and also return a function to remove the callback.
function createStore(defaultValue) {
let value = defaultValue;
const callbacks = [];
const getValue = () => {
return value;
};
const setValue = (newValue) => {
value = newValue;
callbacks.forEach((cb) => cb(newValue));
};
const onChange = (cb) => {
callbacks.push(cb);
return () => {
const index = callbacks.findIndex(cb);
callbacks.splice(index, 1);
};
};
return {
getValue,
setValue,
onChange,
};
}
The implementation is pretty naive.
For example, we will notify all subscribers even if the value didn't change (setValue invoked with the same value twice).
Or, you might argue that setValue should accept a function instead to be able to make atomic changes.
Long story short, there's a room for improvement. But as a skeleton it'll do, and as I promised it's just 28 lines of code.
Let's draw a line here real quick
Here's what we've learned.
- A shared state can be implemented outside of React ecosystem (you don't need Context, or redux, or whatever) as a simple store with callbacks.
- That store can be used to share state even across different components and even different frameworks.
- We need a bit of code to connect our store toa particular framework. For React, it was a simple hook, using
setState
to cause re-render.