Implementing Websocket Plugins for Vuex

Implementing Websocket Plugins for Vuex

Vuex is a state management plugin for Vue.js. It allows you to centrally store all data needed for a Vue application and manage mutations (changes) to that data.

Out of the box it works really well for working with data from an API. But when working with realtime data being pushed to the client over websockets, there isn't a good way to represent that flow in a clean way.

Here is an example. Say we have a Vue application that needs to connect to a SignalR hub to recieve chat messages and display them realtime.

  • Where should you put the SignalR client construction logic?
  • Where should you put the logic to start the SignalR client connection?
  • How do you dispatch SignalR messages into the Vuex store?

This post will cover each of these topics and the concepts outlined here can be used with any websockets library you want.

Setting Up a Chat Store

First, let's setup a Vuex store for handling our realtime chat data. I typically set up Vuex modules for each specific domain my apps need to know about. A simple chat module looks like this.

const state = {
  chatMessages: [],
  limit: 5
};

const getters = {
  displayMessages: state => state.chatMessages
};

const actions = {
  addMessage({ commit }, message) {
    commit('ADD_MESSAGE', message);
  },
  deleteMessage({ commit }, message) {
    commit('DELETE_MESSAGE', message);
  }
};

const mutations = {
  ADD_MESSAGE(state, message) {
    while (state.chatMessages.length >= state.limit) {
      state.chatMessages.shift();
    }
    state.chatMessages.push(message);
  },
  DELETE_MESSAGE(state, message) {
    state.chatMessages = state.chatMessages.filter(m => m.id !== message.id);
  }
};

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
};

This chat module will maintain an array of chat messages for display. The array is limted to five messsages. When a new message is added, the oldest messages is popped off the stack.

Note: I am explicitly using namespaces in the module definition. Here is how you call a namespaced module using the Vuex store.

store.dispatch('chat/addMessage', message);

This call will execute the addMessage action passing it the message object.

Using this module in your Vuex store is pretty simple.

import Vue from 'vue';
import Vuex from 'vuex';

import chat from './modules/chat';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    chat
  }
});

Import the chat module then pass it along to the Vuex store constructor.

Finally, instruct Vue to use the store in main.js.

import Vue from 'vue';
import store from './store';
import App from './App.vue';

Vue.config.productionTip = false;

new Vue({
  store,
  render: h => h(App)
}).$mount('#app');

Note that the Vue instance has no knowledge of how the store is setup, it simply uses it as configured.

We now have a store that is capible of handling our chat message state and making that state available to any Vue component that wants to consume it.

Setting Up a Websocket Plugin

Vuex has a nice plugin model that allows you to add functionality by hooking into a stores mutations. For each websocket connection we can create a plugin that manages the interaction between socket messages we care about and the store.

Here is the basic shell of the plugin we will be building over the next few sections.

export default function createWebSocketPlugin() {
  return store => {
  
  };
}

The module simply exports a function that returns a function with a single parameter: the store itself. To wire it up to the Vuex store, import the plugin and hand it off to the Vuex store constructor plugins array.

import Vue from 'vue';
import Vuex from 'vuex';

import chat from './modules/chat';
import chatSocket from './plugins/chat';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    chat
  },
  plugins: [chatSocket()]
});

Now that the plugin is wired in, we can update it to create a websocket connection.

import { HubConnectionBuilder } from '@aspnet/signalr';

const client = new HubConnectionBuilder()
  .configureLogging(process.env.VUE_APP_SIGNALR_LOG_LEVEL)
  .withUrl(process.env.VUE_APP_SIGNALR_HUB_URL)
  .build();


export default function createWebSocketPlugin() {
  return store => {
      //subscribe to events
    client.start();
  };
}

Here we have imported a websocket library called SignalR. It is a popular way of communicating with .NET server applications, but any websocket library will do.

Then we create a client and start the connection after we have subscribed to any events. The plugin how has access to both a websocket client and the Vuex store.

Subscribing to Connection Events

Our client side Vue application need a way to monitor the connection state of the websocket to ensure it does not try to use a closed connection. Let's subscribe to our first event by handling the connection status of the websocket.

First, update the chat vuex module to keep track of the connection state as well as actions and mutations for updating it.

const state = {
  connected: false,
  error: null,
  chatMessages: [],
  limit: 5
};

const getters = {
  displayMessages: state => state.chatMessages
};

const actions = {
  addMessage({ commit }, message) {
    commit('ADD_MESSAGE', message);
  },
  deleteMessage({ commit }, message) {
    commit('DELETE_MESSAGE', message);
  },
  connectionOpened({ commit }) {
    commit('SET_CONNECTION', true);
  },
  connectionClosed({ commit }) {
    commit('SET_CONNECTION', false);
  },
  connectionError({ commit }, error) {
    commit('SET_ERROR', error);
  }
};

const mutations = {
  ADD_MESSAGE(state, message) {
    while (state.chatMessages.length >= state.limit) {
      state.chatMessages.shift();
    }
    state.chatMessages.push(message);
  },
  DELETE_MESSAGE(state, message) {
    state.chatMessages = state.chatMessages.filter(m => m.id !== message.id);
  },
  SET_CONNECTION(state, message) {
    state.connected = message;
  },
  SET_ERROR(state, error) {
    state.error = error;
  }
};

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
};

We have added a flag to indicate connection status and an error to the state. By default the connection is not available and there is no error state. We have added actions for opening and closing the connection as well as populating the error.

Now we update the plugin to subscribe to connection events and push them to the store.

import { HubConnectionBuilder } from '@aspnet/signalr';

const client = new HubConnectionBuilder()
  .configureLogging(process.env.VUE_APP_SIGNALR_LOG_LEVEL)
  .withUrl(process.env.VUE_APP_SIGNALR_HUB_URL)
  .withAutomaticReconnect()
  .build();

export default function createWebSocketPlugin() {
  return store => {
    client.on('stateChanged', (oldState, newState) => {
      if (oldState !== newState && newState !== 'Connected')
        store.dispatch('chat/connectionClosed');
      else store.dispatch('chat/connectionOpened');
    });

    client
      .start()
      .then(() => {
        store.dispatch('chat/connectionOpened');
      })
      .catch(err => {
        store.dispatch('chat/connectionError', err);
      });
  };
}

Here we are subscribing to the stateChanged event. If the state is anything other than "Connected" we dispatch a closed action to the store otherwise the opened action.

We also handle the start promise states calling the appropriate actions.

Subscribing to Chat Events

The websocket server I am connecting to will raise receiveChatMessage events for each chat message sent to the server. We can easily subscribe to this event in our plugin.

client.on('receiveChatMessage', message => {
  store.dispatch('chat/addMessage', message);
});

The event handler dispatches the addMessage action passing along the received message.

Subscribing to Mutations

Our plugin is successfully managing it's own connection state and pushing messages into the Vuex store. But the application will need to send chat messages as well.

To accomplish that the plugin needs to subscribe to mutations and handle the ones it cares out.

store.subscribe((mutation, state) => {
  if (state.chat.connected && mutation.type === 'chat/SEND_MESSAGE')
    client.invoke('SendMessage', null, message);
});

Here we subscribe to all mutations. Then check the websocket connection state and mutation type before sending the message to the server.

Wrapping Up

This post has described the technique that I have used in my Twitch streaming application Bivrost. If you would like to see how all of this is wired up in an acual application check out the src/client/src/store directory.

Follow me on Mastodon!