Problem
The idea about this code is that it’s a full replacement of Redux — in 22 lines of code.
let state = null
const beforeSetStateCallbacks = []
const afterSetStateCallbacks = []
export const beforeSetState = (fn) => beforeSetStateCallbacks.push(fn)
export const afterSetState = (fn) => afterSetStateCallbacks.push(fn)
export const setState = async (key, value) => {
if (state === null) state = {}
// QUESTION: Should I use Promise.all for performance reason?
// Note that the order might be important though
for (const fn of beforeSetStateCallbacks) await fn(key, value)
state[key] = value
for (const fn of afterSetStateCallbacks) await fn(key, value)
}
export const stateEmpty = () => {
return !state
}
export const getState = (key) => {
if (!state) return null
return state[key]
}
There is no dispatch()
because the whole actions/reducers madness melts away, simple functions are meant to modify the state and then call (for example) setState('userInfo', userInfo)
.
Example usage is here: https://glitch.com/edit/#!/lacy-ornament
The rundown:
index.html
contains this:
<script type="module" src="./one.js"></script>
<my-one></my-one>
The first line defines a custom element my-one
. The second line places that element.
StateMixin.js
contains this:
import { setState, getState, afterSetState, stateEmpty } from './reducs.js'
export const StateMixin = (base) => {
return class Base extends base {
constructor () {
super()
if (stateEmpty()) {
setState('basket', { items: [], total: 0 })
}
this.basket = getState('basket')
afterSetState((k, value) => {
this[k] = { ...value }
})
}
}
}
Basically, something is added to the constructor where if the state isn’t yet defined, it gets assigned a default initial state.
Then, assigningthis.basket
makes sure that the basket
property of the element is current, and afterSetState()
will make sure that further modifications to the basket will also update this.basket
.
This means that any element that has this.basket
as a property will get updated when this.basket
is changed.
one.js
contains this:
import { LitElement, html } from 'lit-element/lit-element.js'
import { StateMixin } from './StateMixin.js'
import { addItemToBasket } from './actions.js'
// Extend the LitElement base class
class MyOne extends StateMixin(LitElement) {
static get properties () {
return {
basket: {
type: Object,
attribute: false
}
}
}
render () {
return html`
<p>Basket</p>
<p>Total items: ${this.basket.total}</p>
<p>Items:</p>
<ul>
${this.basket.items.map(item => html`
<li>${item.description}</li>
`)}
</ul>
<input type="text" id="description">
<button @click="${this._addItem}">Add</button>
`
}
_addItem () {
const inputField = this.shadowRoot.querySelector('#description')
addItemToBasket( { description: inputField.value })
inputField.value = ''
}
}
// Register the new element with the browser.
customElements.define('my-one', MyOne)
This is a very minimalistic element, where a few interesting things happen. First of all, it’s mixed with StateMixin.js
, which will ensure that the constructor deals with status and registration for changes. It also defines a basket
property, which means that when the basket is changed, the element will re-render.
Finally, _addItem()
will run the action addItemToBasket()
which is the only action defined.
actions.js
contains this:
import { getState, setState } from './reducs.js'
export const addItemToBasket = (item) => {
const basket = getState('basket')
basket.items. push(item)
basket.total = basket.items.length
basket.items = [ ...basket.items ]
setState('basket', basket)
}
This is simple: first of all, the state is loaded with getState()
. Then, it’s modified. Note that lit-html only re-renders changed things. So, basket.items is re-assigned. Finally, the state is set with setState()
.
Note: you might have multiple branches in your state: basket
, userInfo
, appConfig
, and so on.
Questions:
- If I use
Promise.all
, I will lose the certainty that the calls are called in order. Do you think I should still do it? - Is there a way to prevent the check on state every single time in
setState()
andstateEmpty()
? The idea is that stateEmpty() returns false if the state has never been initialised. - How would you recommend to implement an “unlisten” function here? As in, what’s the simplest possible path to provide the ability to stop listening?
- Since this is indeed a working 100% replacement on Redux (in 22 lines), shall I name the functions more ala Redux? Redux has “subscribe”, but I like giving the option to subscribe to before and after the change. Maybe
subscribe()
andsubscribeBefore()
?
Solution
I may or may not have seen a certain movie in the cinema, but the Batman in me wants to say this:
Not to mention that semicolons don’t hurt either.
Also, per @Blindman67, it seems that you are pushing complexity down to the callers. It seems to me a number of callbacks would only want to run for a given key
, forcing callers to check the key
value is not good design.
For this part:
Is there a way to prevent the check on state every single time in
setState() and stateEmpty()?
I would declare state
like let state = {};
Then you dont need to check in setState
, because state
is already an Object
.
You would write stateEmpty
like this:
export const stateEmpty = () => {
return !Object.keys(state).length;
}
export const getState = (key) => {
return state[key]
}
This would save you 2 lines, and avoids the akward null
value.
The Joker in me considers your beforeSetStateCallbacks
and afterSetStateCallbacks
as just two parts of the same coin. I have not tested this, but the below should be both possible and cleaner;
let state = {};
const batch = [(key,value)=>state[key] = value];
export const beforeSetState = (fn) => batch.unshift(fn);
export const afterSetState = (fn) => batch.push(fn);
export const setState = async (key, value) => {
for (const f of batch){
await f(key, value);
}
}
In this vein, this question becomes easier:
How would you recommend to implement an “unlisten” function here?
If the subscriber remembers the function they used to subscribe you could go like this, because now you dont care whether the caller listens to before or after.
export const unhook = (fn) => batch.splice(0, batch.length, ...batch.filter(f=>f!=fn));
The last item I want to mention is a graceful exit. Your code is short, because for one thing you trust that the caller will always provide a function for fn
. Consider adding more type checks, otherwise the stack-trace will end frequently in your code.
After @konjin’s great answer, I ended up with this wonderful code:
const state = {}
let beforeCallbacks = []
let afterCallbacks = []
let globalId = 0
const deleteId = (id, type) => {
type === 'a'
? afterCallbacks = afterCallbacks.filter(e => e.id !== id)
: beforeCallbacks = beforeCallbacks.filter(e => e.id !== id)
}
export const register = (fn, type = 'a') => {
const id = globalId++
(type === 'a' ? afterCallbacks : beforeCallbacks).push({ fn, id })
return () => deleteId(id, type)
}
export const setState = async (key, value) => {
for (const e of [...beforeCallbacks, { fn: () => { state[key] = value } }, ...afterCallbacks]) {
const v = e.fn(key, value)
if (v instanceof Promise) await v
}
}
export const stateEmpty = () => !Object.keys(state).length
export const getState = (key) => state[key]
I used his idea in setState()
to create an array where state[key] = value
is sandwiched between the before and after calls.
I followed his advice of assigning {}
to state, and adding curly brackets like Batman said 😀
I implemented de-registration by adding an ID to each one, rather than deleting the function, as I want to make sure I can assign the same function and de-register it without side effects.
His answer IS the accepted answer.
Thanks!