bencodezen / vue-enterprise-boilerplate

An ever-evolving, very opinionated architecture and dev environment for new Vue SPA projects using Vue CLI.
7.78k stars 1.32k forks source link

Reacting to ephemeral state #88

Closed alexdilley closed 5 years ago

alexdilley commented 5 years ago

Following on from my question earlier in Vue Land #q-and-a (Discord) regarding an idiomatic way to react to events initiated in components that are only common via a non-parent ancestor...

Here's an example of toggling a full-screen dialog from the Material Design docs.

Of interest is the navigation icon on the left-side of the app bar. It can transition between a number of representations – 'menu', 'close', 'back', 'up' – depending on context. As well as the icon state, it will have an action attached that relates to the current context – as a menu, it would most commonly be an activator to open the navigation drawer, but on some pages (or at some media query breakpoints) it may instead toggle a sidebar; a close icon may require confirmation from the user – specific to the open dialog – before the dialog is actually closed; etc. Because it has cross-cutting concerns, we could model its state in Vuex:

const OPEN_NAV_DRAWER = 0;
const CLOSE_DIALOG = 1;
...

state: {
  nav: {
    action: OPEN_NAV_DRAWER,
  },
},

getters: {
  icon: ({ action }) => switch (action) ...
},

Perhaps upon mounting a dialog component (v-if="show", say) the action state is mutated (being set to the action the component will require it do [if clicked]), which in turn triggers a transition, and you get the funkeh icon morph animation. Nice. The bit I'm unsure on is how to react accordingly when the navigation icon itself, which isn't closely related in the DOM hierarchy to the dialog it should act on, is clicked/activated/triggered. This is a solution I've come up with but I am uncertain whether I'm missing a more basic alternative:

// nav Vuex module

state: {
  ...,
  activated: false,
},

mutations: {
  toggle(state) {
    state.activated = !state.activated;
  },
},

actions: {
  async activate({ commit }) {
    commit('toggle');
    await this.$nextTick();
    commit('toggle');
  }
},

This gives any interested component one event loop cycle (tick) to pick up on the fact that it needs to react:

// a dialog or nav drawer component, etc

watch: {
  navIconActivated(activated) {
    if (!activated) return;

    if (this.$store.state.nav.action === CLOSE_DIALOG) {
      // only one full-screen dialog can exist at a time, so this must be my cue!
      // ...do my thang
    }
  },
},

methods: {
  ...mapActions('nav', { navIconActivated: 'activated' }),
},

But is there another way? It's presumably not that uncommon to need to react to events triggered from non-child components (with deeper nesting you "could" – in quotes and italics :) – use an event bus). I've also considered the $root.$emit and $root.$on/$off idiom but that can spread logic around a bit and not separate the concern of each component so nicely. Perhaps this suits Vuex the best since it does an okay job at encapsulating each responsibility, I just wonder whether it's akin to using an aircraft carrier to cross the road.

chrisvfritz commented 5 years ago

This sounds like one of the cases where Vuex's default behavior is preferable to namespaced modules. By default, dispatch('myAction') will dispatch all actions called 'myAction', within any module. This essentially means dispatch is emitting an event that any module can listen to.

The downside is that more often than not, I see devs unintentionally create actions with conflicting names across modules, creating bugs. And in very large apps, it can become hard to keep track of which names you've already used. That's why I prefer to disable this behavior by making all modules namespaced by default.

But even with namespaced modules, it's possible to selectively enable this behavior, e.g. with a utility like this:

// file: src/utils/dispatch-all.js

// Utility function to dispatch all matching actions from any namespaced module
export default function dispatchAll(actionNameToTrigger) {
  const store = require('@state/store').default
  const actions = store._actions
  const calledActions = []
  for (const actionName in actions) {
    if (new RegExp(`\/${actionNameToTrigger}$`).test(actionName)) {
      calledActions.push(store.dispatch(actionName))
    }
  }
  return calledActions.length
    ? Promise.all(calledActions)
    : Promise.resolve()
}

Then you could use it in a module like this:

// file: src/state/modules/nav.js

import dispatchAll from '@utils/dispatch-all'

export const actions = {
  activate({ state }) {
    // Dispatch actions in any namespaced module called `onNavIconActivated`
    return dispatchAll('onGlobalNavActivate')
  },
}

And then when activate is called, dispatchAll will also dispatch all actions called onGlobalNavActivate inside any namespaced module, therefore giving you a global event you can listen to. I like to name these with the convention onGlobal + module name + local action name, so that it's really clear that this is a global event and where it's being dispatched from.

Hopefully I understand the problem correctly and didn't tell you something completely irrelevant. 😅 Let me know if that addresses your use case. 🙂

chrisvfritz commented 5 years ago

Btw, I hope I'll see you at Vue.js London next week. 🙂 I mean, you're practically in London already.

alexdilley commented 5 years ago

Thanks, as always, for taking the time to reply 🙇

That's an interesting pattern and utilisation/mimicking of the default, if not always desirable, behaviour – I wasn't even aware that dispatch effectively emits to all modules; I was oblivious in my safe modular bubble your project created for me :)

The technique removes the need to maintain an ephemeral (one-tick-lifetime) activated state, which did feel a bit hacky. It will still require me to record a reference to the relevant actor since only one onGlobalNavActivate action should actually respond to the event at any one time. It might be a bastardisation, but I suppose an alternative solution could be to make that actor be a reference to the relevant module:

// nav Vuex module

dispatch(`${state.actor}/onMainNavClick`, { root: true })

...where state.actor can be the nav module itself (which will defines the default, common action – i.e. open the navigation drawer).

Ideally I guess I'm thinking in terms of event bubbling, where the propagation can be stopped by an interested actor or left to a root listener that performs the default. But the nodes are disparate in the hierarchy in this case, so that's not possible.

Many ways to skin a cat – Vue provides a fantastic toolbox; picking the most appropriate tool can be difficult. Also, knowing how much UI state to make the responsibility of a component versus maintaining in Vuex is something I'm still getting comfortable with. tbh Vuex feels like the correct place to store anything besides low-level concerns of a component – basically Vuex should probably be responsible for anything of any [domain] context. For example, the navigation drawer has state (a prop) to know whether it is open (and is dumb to how/where its used) and its parent component uses Vuex to know whether it should be open (since that's contextual to the application). That does add a layer of complexity in state maintenance but I guess that's just:

<nav-drawer :show="!$store.state.nav.closed"/>
...
dispatch('nav/open')

(I use "closed" because "open" is both an adjective and verb, and hence sometimes ambiguous...clearly "open" is used in its verb form when dispatched, however 😅.)

vs

<nav-drawer :show="!isNavClosed"/>
...
isNavClosed = false

Anyway...tangent!

Thanks for the education – I learnt something today :)

alexdilley commented 5 years ago

Regretfully, I won't be attending Vue.js London 😢 Best of luck with your workshops, though 👍

chrisvfritz commented 5 years ago

Really sorry to hear we won't see you in London! 😞

I wasn't even aware that dispatch effectively emits to all modules; I was oblivious in my safe modular bubble your project created for me

Glad to hear I protected you from a footgun. 😄 To clarify, store.dispatch always emits all modules, but within a namespaced module, the dispatch provided as an argument property only emits to its own module, unless { root: true } is provided as a 3rd argument. You might know all this already, but I want to make it clear for future readers of these issues. 🙂

Ideally I guess I'm thinking in terms of event bubbling, where the propagation can be stopped by an interested actor or left to a root listener that performs the default.

If the order modules to notify is reliable (e.g. always check module A, then module B, then module C), you could possibly dispatch only to module A. Then, if module A decides to continue the propagation, it could emit to module B, which could do a similar check before deciding to emit to module C.

The dynamic dispatch is also potentially a good way to go though. 🙂

Many ways to skin a cat – Vue provides a fantastic toolbox; picking the most appropriate tool can be difficult. Also, knowing how much UI state to make the responsibility of a component versus maintaining in Vuex is something I'm still getting comfortable with.

I haven't mastered it either. 😄 Programming in general, I often can't find a completely ideal solution and instead deciding which compromises I'm most comfortable with.

As always, thanks for the great questions. 🙂

alexdilley commented 5 years ago

Coming back to this (sorry to comment on a closed issue!), a very simple solution could be this:

tl;dr: in essence, clicking on the main navigation icon emits on $root the event type currently set as state in the store; interested parties then listen to events they care about.

<!-- file: src/components/top-app-bar.vue -->

<button @click="$root.$emit($store.state.page.navigationType)">
// file: src/router/views/my-page.vue

computed: {
  ...mapGetters('window', ['breakpoint']),
},

watch: {
  breakpoint: { handler: 'setPageNavigationType', immediate: true },
},

methods: {
  setPageNavigationType(breakpoint) {
    // Open the drawer on small screens; toggle the sidebar (since there's room to
    // show it) on large screens.
    this.$store.commit(
      'page/setNavigationType',
      ['lg', 'xl'].includes(breakpoint) ? 'sidebar:toggle' : 'drawer:open'
    );
  },
},
// file: src/components/drawer.vue

created() {
  this.$root.$on('drawer:open', this.open);
},

beforeDestroy() {
  this.$root.$off('drawer:open', this.open);
},

methods: {
  open() {
    this.show = true;
  },
},

...omitting logic for sidebar, but another, more generic, example being:

// file: src/components/dialog.vue

created() {
  this.$root.$on('dialog:close', this.close);
},

beforeDestroy() {
  this.$root.$off('dialog:close', this.close);
},

methods: {
  close() {
    this.$emit('close');
  },
},

My question is: isn't this just mimicking the [frowned upon] – pattern of an event bus; $root here simply acting as a replacement to $bus (i.e. a globally accessible Vue instance)? If so, this could be argued to be a code smell. But it does seem to provide the cleanest solution, that uses events in the manner they're intended rather than some complex alternative just to ensure logic is somehow incapsulated in the store (but where the store isn't naturally the best fit for event emission/handling)?

(Kind of just musing here! Don't feel you need to respond! Pasting example code may at least provide potential interest to anyone that finds this in the future...not that the solution is rocket science by any means!)

chrisvfritz commented 5 years ago

It's never a problem to comment on a closed issue with new questions/information. 🙂 Yeah, I would say the event bus is code smell, partly because it can be difficult to trace all the listeners and the order in which things are happening.

One thing about watchers that people sometimes forget is that the second argument will contain the old value (e.g. handler(newValue, oldValue) {), so you can have state machine logic in a handler.