andrew bastin
Intro to DispatchingStore and the new Hoppscotch State System
Mar 23, 2021 · #hoppscotch, #architecture, #dispatchingstore · 16 min read

I think it is about time I start blabbering about stuff in Hoppscotch over here ;)

Hoppscotch is (as of writing this post) a collection of around 90 Vue components working to provide the powerful yet simple experience to the user for building, experimenting and testing their REST, GraphQL and Realtime APIs. Due to the nature of the functionality of Hoppscotch being a lot more data level with many background features like history and collection syncing, integration with the browser extension and many other integration, our code is more heavy on the background. By background I mean, code which doesn't directly relate with the operations and interactions from the UI.

How does Hoppscotch do state ?

Like almost every other Vue powered web app. Hoppscotch uses Vuex for state management. Now, Vuex is pretty cool. We dispatch actions which commit mutations and those mutations mutate the state which is detected by Vue's observable system which in turn updates the UI. This allows for pretty transparent usage of the store from within the Vue components. In Hoppscotch, every Vue component can access the store from the $store property. This pretty much ties the store and the state stored in it to the UI. Only the UI components can directly access the store.

'State drilling'

This property of Vuex to keep the store accessible only from the UI properly and the fact the Hoppscotch is a lot more background heavy webapp leads to an issue what I like to call 'state drilling'. The term is a derivative of the term 'prop drilling'. Prop Drilling is a phenomenon in React where we keep passing down the data through props of intermediary components just so that a deeply nested child can access the data. In case of Hoppscotch, we end up with the situation where the background helper code needs to access maybe a setting or a configuration from the store and since they can't access them on their own, the UI has to pass it from the triggering code. An example for this can be seen from the network strategies bit of the code. Network Strategies system exists to abstract out to the UI how the API request is actually ran, whether it is passed to the Hoppscotch proxy or is it running through the browser extension. Network Strategies needs to know whether the proxy or extension is enabled in order to route the request through them. This is present in the settings data of the app, which is exposed via the Vuex store. Since, the strategies code can't access those states. The state has to be passed down to the component so that it can be used.

const isExtensionsAllowed = ({ state }) =>
typeof state.postwoman.settings.EXTENSIONS_ENABLED === "undefined" ||
state.postwoman.settings.EXTENSIONS_ENABLED

const runAppropriateStrategy = (req, store) => {
if (isExtensionsAllowed(store) && hasExtensionInstalled()) {
return ExtensionStrategy(req, store)
}

return AxiosStrategy(req, store)
}

export const sendNetworkRequest = (req, store) =>
runAppropriateStrategy(req, store).finally(() => window.$nuxt.$loading.finish())

Another notable behaviour due the way how the store works currently is how these 'detached' systems need to be explicitly informed by the UI to perform updates. For example, Hoppscotch does syncing which is powered by Firebase (via Firestore). To make it easy to mock and to catch bugs easily, the entire implementation of what we are doing with Firestore is implemented on a single file named fb.js. It can independently manage firebase related stuff as Firebase is globally accessible as a whole on the app and provide listeners to hook into relevant info. But fb.js code can't listen to the store or even access it and hence, whenever some change is made from the UI, to reflect that onto Firestore, we have to manually perform the action from the component responsible for the interaction. This leads to code repetition and can also prove to be prone to mistakes as that is code we have to write and hope not to forget or get wrong.

Here is a section of a component responsible for adding a GraphQL collection


<script>
import { fb } from "~/helpers/fb"

export default {
props: {
show: Boolean,
},
data() {
return {
name: undefined,
}
},
methods: {
syncCollections() {
if (fb.currentUser !== null && fb.currentSettings[0]) {
if (fb.currentSettings[0].value) {
fb.writeCollections(
JSON.parse(JSON.stringify(this.$store.state.postwoman.collectionsGraphql)),
"collectionsGraphql"
)
}
}
},
addNewCollection() {
if (!this.$data.name) {
this.$toast.info(this.$t("invalid_collection_name"))
return
}
this.$store.commit("postwoman/addNewCollection", {
name: this.$data.name,
flag: "graphql",
})
this.$emit("hide-modal")
this.syncCollections()
},
hideModal() {
this.$emit("hide-modal")
this.$data.name = undefined
},
},
}
</script>

Here is another instance of the same kind of behaviour, in a Vue component responsible for managing Environments

  methods: {
syncEnvironments() {
if (fb.currentUser !== null && this.SYNC_ENVIRONMENTS) {
fb.writeEnvironments(JSON.parse(JSON.stringify(this.$store.state.postwoman.environments)))
}
},
clearContent({ target }) {
this.$store.commit("postwoman/removeVariables", [])
target.innerHTML = this.doneButton
this.$toast.info(this.$t("cleared"), {
icon: "clear_all",
})
setTimeout(() => (target.innerHTML = '<i class="material-icons">clear_all</i>'), 1000)
},
addEnvironmentVariable() {
let value = { key: "", value: "" }
this.$store.commit("postwoman/addVariable", value)
this.syncEnvironments()
},
removeEnvironmentVariable(index) {
let variableIndex = index
const oldVariables = this.editingEnvCopy.variables.slice()
const newVariables = this.editingEnvCopy.variables.filter(
(variable, index) => variableIndex !== index
)

this.$store.commit("postwoman/removeVariable", newVariables)
this.$toast.error(this.$t("deleted"), {
icon: "delete",
action: {
text: this.$t("undo"),
onClick: (e, toastObject) => {
this.$store.commit("postwoman/removeVariable", oldVariables)
toastObject.remove()
},
},
})
this.syncEnvironments()
},

These syncSomething() functions are becoming increasingly common the more stuff we decide to sync up and it is a bit and we have seen our fair share of bugs which occurred just because we synced up the data without proper processing or just plainly just forgetting to sync.

A public state

I think those above snippets clearly elaborate the shortcomings of our current Vuex-based state management ways. This inspired me to go searching for solutions that solve the following issues

  • The state should be accessible from any kind of code in the app, both the UI and the detached 'services' like Network Strategies and Firebase
  • The state should allow for a mechanism that allows both UI and the services to listen to state changes. It would also be really cool if we can listen to certain sections of the state.
  • Plays into the Dispatch-Update style. With the dispatches being able to be listened to in a fashion similar to events by concerned parties.

Meet the DispatchingStore

I couldn't find a solution that really fit the above criterions in a way I was happy with. Along with this, I was familiarizing myself a lot more about Reactive Programming and its paradigms which lead to the creation of the DispatchingStore.

Here is the entire implementation of the store. It is written in TypeScript.

import { Subject, BehaviorSubject } from "rxjs"
import { map } from "rxjs/operators"
import assign from "lodash/assign"
import clone from "lodash/clone"


type Dispatch<StoreType, DispatchersType extends Dispatchers<StoreType>, K extends keyof DispatchersType> = {
dispatcher: K & string,
payload: any
}

export type Dispatchers<StoreType> = {
[ key: string ]: (currentVal: StoreType, payload: any) => Partial<StoreType>
}

export default class DispatchingStore<StoreType, DispatchersType extends Dispatchers<StoreType>> {

#state$: BehaviorSubject<StoreType>
#dispatchers: Dispatchers<StoreType>
#dispatches$: Subject<Dispatch<StoreType, DispatchersType, keyof DispatchersType>> = new Subject()

constructor(initialValue: StoreType, dispatchers: DispatchersType) {
this.#state$ = new BehaviorSubject(initialValue)
this.#dispatchers = dispatchers

this.#dispatches$
.pipe(
map(
({ dispatcher, payload }) => this.#dispatchers[dispatcher](this.value, payload)
)
).subscribe(val => {
const data = clone(this.value)
assign(data, val)

this.#state$.next(data)
})
}

get subject$() {
return this.#state$
}

get value() {
return this.subject$.value
}

get dispatches$() {
return this.#dispatches$
}

dispatch({ dispatcher, payload }: Dispatch<StoreType, DispatchersType, keyof DispatchersType>) {
if (!this.#dispatchers[dispatcher]) throw new Error(`Undefined dispatch type '${dispatcher}'`)

this.#dispatches$.next({ dispatcher, payload })
}
}

As you can see, it is a fairly simple and small thing. But it is completely observable. You can listen to the store directly via the subject$ property (the dollar sign indicates that is a RxJS Observable) or you can access the current value directly from the aptly named value property. Along with that, the dispatches are observable as well via the dispatches$ property. To make a DispatchingStore, you need to provide an initial state along with the dispatchers the store accepts. The dispatchers are a JS object with the keys being the name of the dispatcher and the values being a function which accepts the current state of the store and the payload passed by the dispatch function. The return value is expected to be the changes in the relevant keys of the state. Similar to React's setState.

How does this solve Hoppscotch ?

So, this is cool and all. But how can we apply this store practically. Well, DispatchingStore solves all the problems I listed before. It is completely agnostic of the UI, so you can make it accessible through a simple singleton or just a plain exported variable. You can selectively listen to the updates (you can leverage the pluck RxJS operator to help with that) from both the UI and the services because they are just plain RxJS Observables. You can even listen to the dispatches by themselves so you can react according to specific actions on the store.

At Hoppscotch, we are currently at the process of slowly migrating completely away from Vuex to the new store system powered by this. This is a really scary and tedious process because we have to make sure we haven't broken any code with the migration. We are also overhauling the way we persist data along with it so that migration is also affected by this store update. As for the UI integration of the new store, The vue-rx Vue plugin really helps with providing a really good interface for interfacing Vue components with RxJS observables. We have currently ported the Settings system to completely work on DispatchingStore (and it is in production now!), so you can use that as a reference of how DispatchingStore can be used.

What's next ?

I am not really sure actually. I might plan to make this a library if people actually want this. Or maybe even if not :P I haven't published a NPM package before so I would love to have a peek at the process. I want to expand a bit more on improving TypeScript support, I want to figure out if it is possible to get the payload type from the parameter of the function from the Dispatchers list. As for features to the store, it will completely depend on the needs of Hoppscotch currently. I am currently in the process of also re-structuring the code in to work as independent isolated 'services' interfaced by Observables completely, but that is a topic for a later post, for when I actually decide to post again.