5
\$\begingroup\$

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() and stateEmpty()? 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() and subscribeBefore()?
\$\endgroup\$
8
  • \$\begingroup\$ @Merc, please add a testable jsfiddle.net link (or alike) with code (and sample object) \$\endgroup\$ Commented Oct 27, 2019 at 13:38
  • \$\begingroup\$ OK doing it now \$\endgroup\$
    – Merc
    Commented Oct 27, 2019 at 21:14
  • \$\begingroup\$ OK done. @RomanPerekhrest doing this was a JOB AND A HALF :D I hope what I wrote makes sense. I really do look (humbly) forward to comments and reviews. \$\endgroup\$
    – Merc
    Commented Oct 27, 2019 at 22:46
  • 1
    \$\begingroup\$ I just saw the tag "reinventing-the-wheel". I think we should also add the tag "simplifying-an-overcomplex-wheel" :D \$\endgroup\$
    – Merc
    Commented Oct 27, 2019 at 23:18
  • \$\begingroup\$ I am not a redux user so my knowledge is limited. However I have some questions? There is no state history?, The sate is being mutated and holding references, not copies. Are you relying on the components to ensure only copies are passed and that the callbacks do not mutate the state value? The callbacks (Before, and After) are state global, this seams very clumbersum for large states models. Is the need to be 22 lines forcing the code using your mini redux to be longer? \$\endgroup\$
    – Blindman67
    Commented Oct 28, 2019 at 10:56

2 Answers 2

4
\$\begingroup\$

I may or may not have seen a certain movie in the cinema, but the Batman in me wants to say this:

enter image description here

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.

\$\endgroup\$
7
  • \$\begingroup\$ GOLDEN. I will implement every single suggestion here. Now... how do I do this? I don't want to change the question. Shall I add the updated code as a trailing update? Sorry, I am a StackOverflow guy, I don't spend enough time here at CodeReview! \$\endgroup\$
    – Merc
    Commented Oct 28, 2019 at 13:15
  • \$\begingroup\$ I love your suggestion for batched callback, and how it starts with one prefefined call in the middle. My only problem here is that developers will expect beforeSetState() calls to be run in the order they are defined, whereas with your code they will be run in reversed order. Right? \$\endgroup\$
    – Merc
    Commented Oct 28, 2019 at 13:22
  • \$\begingroup\$ @Merc after your question has been answered you should NOT change your question! \$\endgroup\$
    – Blindman67
    Commented Oct 28, 2019 at 13:23
  • \$\begingroup\$ About unlistening, a user could want to listen to the same function twice, and only desire to "unlisten" to a specific one. Redux does it by making sure that register() returns a function that will remove that registration. But... I am having trouble finding a nice, reliable way to implement that \$\endgroup\$
    – Merc
    Commented Oct 28, 2019 at 13:25
  • \$\begingroup\$ I need to think on the order and unhook points, they are both valid. \$\endgroup\$
    – konijn
    Commented Oct 28, 2019 at 13:42
1
\$\begingroup\$

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 :D

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!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.