Phoenix channels organisation within javascript

By now I’m using the socket.js shipped with phoenix to setup the socket connect and join different channels, which I then export. Then I import those channels within my (vue) components and add the event handlers needed by that component there like NotificationChannel.on(…).

My problem with that setup is that I expect phoenix presence to have a race condition if I setup the event handler for receiving the initial state only after I joined the channel. It might be possible for the handler to be registered only after the initial presence state reached the client.

So my question is how do you guys structure your code around channels, do you have an central place to connect to all the channels? How do you decide where to add your event listeners, especially if multiple components need access to a channel?

1 Like

Not an answer (because I haven’t delved into channels too deeply) but it may be an idea to look into tying the channel to a dedicated Event Bus and have the components interact with that instead while the Event Bus deals with the particulars of the channel.

1 Like

If your components need to do changes upstream (e.g. change data that affects several components) creating an event bus is the way to go, as it allows you to cleanly do $emit and propagate those changes appropriately - although I’ve found that it’s fairly trivial to do it without eventbus, although much less “clean”.

If your data flows downstream with no upstream changes, you don’t need an eventbus and simply setting data on your “main” vue instance, that trickles down to the components is enough?
In the sample I’m posting below I didn’t use an eventbus, but I’m most certainly looking at implementing it whenever I have free time and once the engine is fully in place.
For instance this is what I have for a game, the player joins he’s connected to the duel room (in this room only he and opponent are connected, and then he joins his own user channel on the callback from the “join”). I don’t use props because the data on the child changes constantly and I found it easier to just do it like this, but props are the regular way of passing downstream info.

// lots of mixins
Vue.component('Game', Game)
    new Vue({
      el: 'main',
      data() {
        return {
          channel: null,
          messages: [],
          user_id: null,
          user_channel: null,
          your_timer: 0,
          opponent_timer: 0,
          you: null,
          opponent: null,
          your_game: null,
          opponent_game: null,
          status: null,
          original_aether: null,
          stack: null,
          action: null,
          finished: false,
          winner: false
        }
      },
      mounted() {
          this.channel = socket.channel("duel:"+window.gameId, {token: window.userToken});
        this.channel.join()
          .receive("ok", response => {
            if (response.game.finished) {
              // ....
            }
            this.user_id = response.user_id;
            this.you = response
            // set all data from the response in their particular fields
           // then I pass "this" vue instance to the method so that I can set directly on the vue instance the user channel
           joinUserChannel(this, response.user_id);
        }
     }
     function joinUserChannel(that, id) {
          that.user_channel = socket.channel("user:"+id, {token: window.userToken});
          // appropriate handlers
     }

Then on the game vue component:
I have things like this:

<script>
export default {
  data() {
    return {
      message: "",
      selected: false,
      cast: false,
      to_pay: "",
      payment: false,
      ready_cast: false,
      to_strike: {},
      to_guard: {},
      legal_guards: {},
      order_guarders: {},
      scroll_cast: {
        active: false,
        targets: [],
        targets_number: 0
      },
      gifts: {
        active: false,
        what: false,
        gifts: []
      },
      player_area_switch: false,
      opponent_area_switch: false,
      show_forsaken: false,
      show_tomb: false,
      clock: null
    }
  },
  computed: {
    status: function() {
      if (this.$parent.status) {
        return this.$parent.status
      } else {
        return false
      }
    },
    grimoire: function() {
      if (this.$parent.your_game) {
        return this.$parent.your_game.grimoire
      } else {
        return []
      }
    },
    opponent_game: function() {
      if (this.$parent.opponent_game) {
        return this.$parent.opponent_game
      } else {
        return []
      }
    }
    // plenty of other stuff
  }
}

I then have handlers to deal with the messages arriving from the sockets, which change the parent data structure, and since those are computed on the component, when the underlying data changes it triggers a new computation and it’s kept in synch by that. In this case there’s only 1 component 1 instance, but a lot of data fields, so it makes sense to me. If you have a lot of components probably just passing them down as props is better?

1 Like

also non answer, from somebody not on vue:

I keep the channels in one place, but I also only connect to two channels (public_users, and private_user:user_id), so the channel.on is set before the .join - and then emits events (react native with redux/redux-observable) that the different “reducers”/“epics” catches…

I’m having a great time using redux-observable (really rxjs) so you might want to look into rxjs with vue (it’s wonderful when you can wait for multiple events, or “race” different events for completion etc), otherwise something like the event bus or similar sounds like a thing to do for the event handling…

2 Likes

Not on Vue too, but in React, I am using a High Order Component, that wrap loading/unloading channel on mount/unmount.

I don’t know if it applies to Vue too. The advantage is if the channel is in error, I can switch view to an error channel display, before the real component load.

Then, in the router, I wrap the component inside a WithChannel HOC.

      <Route path="/lobby"
        component={Authentication(
          WithChannel({topic: 'lobby'})(LobbyView)
        )} />
      <Route path="/rooms/:id"
        component={Authentication(
          WithChannel({topic: 'room'})(RoomView)
        )} />
1 Like

I have been trying redux-observable and it looks really nice. But I still have some problem connecting an event producer to the channel, can You tell me how You do connect both?

Do You connect to the socket? or do You connect to the channel? and do You use Observable.fromEvent?

Thank You

1 Like

Thanks for all you suggestions.

My issues where that I didn’t want to litter all the channel connecting code in any one vue instance. Also an event-bus works for forwarding the received events, but in the case of presence events if you miss the initial syncState callback you’re done so I’d need to receive those presence events in a global place and rather share the state than the events.

I’ve now gone with storing the state of notifications / presences in a similar fashion to @amnu3387 in my root instance and use computed properties in components which simply access this.$root.$data.notifications and others. To keep my root instance as clean as possible I export some functions like the following from the socket.js:

  joinTeams (preJoin) {
    let TeamsChannel = socket.channel(`team:lobby`, {})

    TeamsChannel.on("teams_lists", ({teams: teams}) => {
      teams.forEach(team => {
        let channel = socket.channel(`team:${team.id}`)

        preJoin(channel, team)

        channel.join()
          .receive("ok", resp => { console.log("Joined successfully", resp) })
          .receive("error", resp => { console.log("Unable to join", resp) })
      })
    })

    TeamsChannel.join()
  }

So my root vue instance only needs to supply the relevant callbacks, while the channel boilerplate is contained elsewhere.

2 Likes

Are you not using a global application state (i.e. a Flux-like architecture)?
If so, take a look at Vuex which is a great implementation of this concept for Vue.

I find that implementing global state makes working with JS applications much easier and in your case specifically, it would be possible to eliminate all race conditions.

Maybe I should write my next blog post about that … :thinking:

1 Like

I have a “main” socket Web Component.
It’s loaded on the main page.
It connects to the socket and it has methods for channels to join and methods for .on events.
Subsequent pages import components that extend the “main socket” component and override his methods for channels to join and .on events.
Hope this helps! : )

1 Like

I’m aware of vuex, but vuex wouldn’t have solved any of the mentioned issues significantly different than what I ended up doing without vuex. I just used the root vue instance as global state instead of a separate vuex store. My issues where more around how to handle the communication part between phoenix channels <-> global store, then they where about integrating a global store in vue.

1 Like

Having a dedicated global store makes it easier to reason about the data flow.
So it’s only natural you ended up with a similar solution eventually but Vuex would give you some cool convenience methods so you don’t have to reimplement the whole store mechanism :slight_smile:

2 Likes

With Vuex I think it would make sense to encapsulate socket.js and the channel based code in an event API module. That module would then export some functions for emitting outgoing events. For initialization a function would start the process of listening for events but would also receive a set of functions used to forward any incoming event to the store.

The Vue components render entirely based on the state contained in the store. The following page demonstrates this in principle:

The eventApi module simulates

  • An incoming ('increment', quantity) event. When the event arrives a callback (_eventCb_) configured during initialization is used to forward the quantity to the store.
  • An outgoing ('changeIncrement', delta) event to adjust the “increment quantity”. The module exposes the changeIncrement function to “emit” this event.
  • An outgoing ('changeInterval', delta) event to adjust the event interval. The module exposes the changeInterval function to “emit” this event.

The mutations module contains the store’s mutation functions (and their names).

The actions module contains the store’s action functions (and their names). Both the changeIncrement and changeInterval actions first invoke the equivalent functions of the eventApi module before committing the change to the store.

The store module (src/store/index.js) is the Vuex store. The mutations and actions from the modules of the same name are simply “spliced” into the store.

The App component rendering only depends on the store.

The ButtonIncrement component is used by App.

The app module creates the store and the Vue instance. The root instance also starts up the event listening process in the mounted life cycle event where it creates the function callback that eventApi uses to update the store (the listening process is stopped in beforeDestroy).

<!DOCTYPE html>
<html>
  <!-- index.html -->
  <head>
    <script src="https://unpkg.com/vue"></script>
    <script src="https://unpkg.com/vuex"></script>
  </head>
  <body>
    <div id="app"></div>

    <script>
     // -------------------
     // src/api/eventApi.js

     const eventApiImport = (() => {

       function start (increment, interval, eventCb) {
         _increment = increment;
         _interval = interval;
         _eventCb = eventCb
         scheduleEvent();
       }

       function stop () {
         if (_timeoutId) {
           window.clearTimeout(_timeoutId);
           _timeoutId = null;
         }
       }

       // process "outgoing" events
       function changeIncrement(n) {
         _increment += n;
       }
       function changeInterval(n) {
         _interval += n;
       }

       // simulate "incoming" events
       function scheduleEvent () {
         _timeoutId = window.setTimeout(emit, _interval);
       }
       function emit () {
         _timeoutId = null;

         // fake incoming event
         _eventCb(_increment);
         scheduleEvent();
       }

       var _eventCb; // callback for "increment" event

       // the rest is faking server state
       var _timeoutId;
       var _increment;
       var _interval;

       return {
         exportFunctionStart: start,
         exportFunctionStop: stop,
         exportFunctionChangeIncrement: changeIncrement,
         exportFunctionChangeInterval: changeInterval
       };

     })();

     // ----------------------
     // src/store/mutations.js

     const mutationsImport = (() => {
       const types = {
         CHANGE_INCREMENT: 'CHANGE_INCREMENT',
         CHANGE_INTERVAL: 'CHANGE_INTERVAL',
         INCREMENT: 'INCREMENT'
       };

       return {
         exportTypes: types,
         exportDefault: {
           [types.CHANGE_INCREMENT] (state, n) {
             state.incrementValue += n;
           },
           [types.CHANGE_INTERVAL] (state, n ) {
             state.intervalValue += n;
           },
           [types.INCREMENT] (state, n ) {
             state.counter += n;
           }
         }
       };
     })();

     // --------------------
     // src/store/actions.js

     const actionsImport = (() => {
       // simulate
       // import {
       //   changeIncrement as changeIncrementEvent,
       //   changeInterval as changeIntervalEvent
       // } from '../api/eventApi';
       const changeIncrementEvent =
         eventApiImport.exportFunctionChangeIncrement;
       const changeIntervalEvent =
         eventApiImport.exportFunctionChangeInterval;
       // simulate
       // import { types as mutation } from './mutations';
       const mutation = mutationsImport.exportTypes;

       const types = {
         CHANGE_INCREMENT: 'changeIncrement',
         CHANGE_INTERVAL: 'changeInterval',
         INCREMENT: 'increment'
       };

       return {
         exportTypes: types,
         exportDefault: {
           [types.CHANGE_INCREMENT] ({ commit }, n) {
             changeIncrementEvent(n); // emit outgoing event
             commit(mutation.CHANGE_INCREMENT, n);
           },
           [types.CHANGE_INTERVAL] ({ commit }, n) {
             changeIntervalEvent(n); // emit outgoing event
             commit(mutation.CHANGE_INTERVAL, n);
           },
           [types.INCREMENT] ({ commit }, n) {
             commit(mutation.INCREMENT, n); // incoming event action
           }
         }
       };

     })();

     // ------------------
     // src/store/index.js

     const storeImport = (() => {

       // simulate
       // import actions from './actions';
       const actions = actionsImport.exportDefault;
       // simulate
       // import mutations from './mutations';
       const mutations = mutationsImport.exportDefault;

       Vue.use(Vuex);

       const incrementDelta = 1;
       const intervalDelta = 500;

       const createStore = () => {
         return new Vuex.Store({
           state: {
             counter: 0,
             intervalDelta,
             incrementDelta,
             intervalValue: intervalDelta,
             incrementValue: incrementDelta
           },
           mutations,
           actions
         });
       };

       return {
         exportFunctionCreateStore: createStore
       };

     })();

     // -----------
     // src/App.vue

     // simulate
     // import { types as action } from './store/actions';
     const action = actionsImport.exportTypes;

     Vue.component('app', {
       template: `<div id="app">
         <div>
           <p>Counter: {{counter}}</p>
           <p>Increment: {{incrementValue}}</p>
           <p>Interval: {{intervalValue}}</p>
         </div>
         <div>
           Change Increment:
           <button-increment
             :quantity="incrementDelta"
             :handler="changeIncrement" />
           <button-increment
             :quantity="-(incrementDelta)"
             :handler="changeIncrement" />
         </div>
         <div>
           Change Interval:
           <button-increment
             :quantity="intervalDelta"
             :handler="changeInterval" />
           <button-increment
             :quantity="-(intervalDelta)"
             :handler="changeInterval"
             :disabled="isMinInterval"/>
             ms
         </div>
       </div>`,

       methods: {
         ...Vuex.mapActions([
           action.CHANGE_INCREMENT,
           action.CHANGE_INTERVAL
         ])
       },

       computed: {
         isMinInterval () {
           return (this.intervalDelta >= this.intervalValue);
         },
         ...Vuex.mapState([
           'counter',
           'incrementValue',
           'intervalValue',
           'incrementDelta',
           'intervalDelta'
         ])
       }

     });

     // ----------------------------------
     // src/components/ButtonIncrement.vue
     Vue.component('button-increment', {
       template:
         `<button @click="increment" :disabled="disabled">{{ label }}</button>`,

       props: {
         'quantity': {
           type: Number,
           default: 1
         },
         'disabled': {
           type: Boolean,
           default: false
         },
         'handler': {
           type: Function,
           required: true
         }
       },

       methods: {
         increment () {
           this.handler(this.quantity);
         }
       },

       computed: {
         label () {
           const text = this.quantity.toString();
           return (this.quantity > 0 ? '+' + text : text);
         }
       }
     });

     // ----------
     // src/app.js

     // simulate
     // import {
     //   start as startEvents,
     //   stop as stopEvents
     // } from './api/eventApi';" simulation
     const startEvents = eventApiImport.exportFunctionStart;
     const stopEvents = eventApiImport.exportFunctionStop;

     // simulate
     // import { createStore } from './store';
     const createStore = storeImport.exportFunctionCreateStore;

     new Vue({
       el: app,
       store: createStore(),
       render: h => h('app'),

       mounted () {
         const vm = this;

         startEvents(
           vm.$store.state.incrementDelta,
           vm.$store.state.intervalDelta,
           (amount) => {
             vm.$store.dispatch(action.INCREMENT, amount);
           }
         );
       },

       beforeDestroy () {
         stopEvents();
       }

     });

    </script>
  </body>
</html>
2 Likes