DEV Community

Cover image for Making WebSocket in Sync With User Internet Connectivity in React Using Redux Part 2
jsmanifest
jsmanifest

Posted on • Originally published at jsmanifest.com

Making WebSocket in Sync With User Internet Connectivity in React Using Redux Part 2

In part one of this series we configured a react application with redux and defined our state structure for internet state updates along with actions that invoke the updates. We created a useInternet hook to register the necessary event handlers to allow the app to invoke actions to change and update the state accordingly.

In this tutorial we will go ahead and enhance the app further by implementing WebSocket functionality to the app. We will make sure that when there are changes in the user's internet connectivity, the websocket client will stay in sync and respond appropriately.

Additionally we will add some extra UX after the websocket client unexpectedly closes. When the websocket client closes unexpectedly, we will make it revive itself.

Note: This tutorial requires you to understand a little about the websocket API.

Note #2: If you want to download the source code for this tutorial you can go ahead and clone it from the repository.

Create the reducers

We will start by creating the reducers for the websocket state updates. If you remember from part one of this tutorial we coded something like this:

import { INTERNET_ONLINE, INTERNET_OFFLINE } from '../actions'

const initialState = {
  internet: {
    isOnline: true,
  },
  ws: {
    connecting: false,
    opened: false,
  },
}

const appReducer = (state = initialState, action) => {
  switch (action.type) {
    case INTERNET_ONLINE:
      return { ...state, internet: { ...state.internet, isOnline: true } }
    case INTERNET_OFFLINE:
      return { ...state, internet: { ...state.internet, isOnline: false } }
    default:
      return state
  }
}

export default appReducer
Enter fullscreen mode Exit fullscreen mode

Looking at the the ws state slice we need to have a component listening to ws.opened which will change when our websocket client opens or closes.

We'll begin by creating a custom useWebsocket hook and importing the useSelector function from redux to listen for those updates:

src/hooks/useWebsocket.js

import { useSelector } from 'react-redux'

const useWebsocket = ({ isOnline }) => {
  const opened = useSelector((state) => state.app.ws.opened)

  return {
    opened,
  }
}

export default useWebsocket
Enter fullscreen mode Exit fullscreen mode

We'll then create a UI component that will receive opened as a prop:

src/components/WebsocketConnection.js

import React from 'react'
import './styles.css'

const StatusMessage = ({ opened }) => (
  <h5>Your websocket is {opened ? 'opened' : 'not opened'}</h5>
)

const BodyContent = ({ opened }) => (
  <div>
    <p>
      {opened && 'Now go do stuff and have an amazing day!'}
      {!opened &&
        "You can't do anything right now. Make yourself a cup of coffee in the mean time."}
    </p>
  </div>
)

const WebsocketConnection = ({ opened }) => {
  return (
    <div className='wsc-container'>
      <div>
        <StatusMessage opened={opened} />
        <BodyContent opened={opened} />
      </div>
    </div>
  )
}

export default WebsocketConnection
Enter fullscreen mode Exit fullscreen mode

Using the App.js component from the last tutorial, we will use this to import the useWebsocket hook we just created so we can grab the opened state and pass it over to our UI component we just created:

src/App.js

import React, { useEffect } from 'react'
import useInternet from './hooks/useInternet'
import useWebsocket from './hooks/useWebsocket'
import './App.css'
import WebsocketConnection from './components/WebsocketConnection'

const App = () => {
  const { isOnline } = useInternet()
  const { opened } = useWebsocket({ isOnline })

  useEffect(() => {
    console.log(
      `%cYou are ${isOnline ? 'online' : 'offline'}.`,
      `color:${isOnline ? 'green' : 'red'}`,
    )
  }, [isOnline])

  return (
    <div>
      <h1>Making Websocket in Sync with the User's Internet Connectivity</h1>
      <hr />
      <WebsocketConnection opened={opened} />
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

I went ahead and applied some quick CSS styles to make it look a little appealing. I provided them here if you want to use them too:

src/components/styles.css

div.wsc-container {
  padding: 35px;
  display: flex;
  align-items: center;
  justify-content: center;
}

div.wsc-container > div:first-child {
  text-align: center;
}
Enter fullscreen mode Exit fullscreen mode

And this is what we have now:

your websocket is not opened. make yourself a cup of coffee in the mean time

At the moment our components won't do anything when the websocket client opens besides what's shown in the screen. That's because we haven't applied action creators for the reducers yet.

First we will create three constants for the action creators:

export const WS_CONNECTING = 'WS_CONNECTING'
export const WS_OPENED = 'WS_OPENED'
export const WS_CLOSED = 'WS_CLOSED'
Enter fullscreen mode Exit fullscreen mode

Then, we're going to need to create the three action creators so that the reducers will be able to communicated with:

src/actions/index.js

export const INTERNET_ONLINE = 'INTERNET_ONLINE'
export const INTERNET_OFFLINE = 'INTERNET_OFFLINE'
export const WS_CONNECTING = 'WS_CONNECTING'
export const WS_OPENED = 'WS_OPENED'
export const WS_CLOSED = 'WS_CLOSED'

export const internetOnline = () => ({
  type: INTERNET_ONLINE,
})

export const internetOffline = () => ({
  type: INTERNET_OFFLINE,
})

export const wsConnecting = () => ({
  type: WS_CONNECTING,
})

export const wsOpened = () => ({
  type: WS_OPENED,
})

export const wsClosed = () => ({
  type: WS_CLOSED,
})
Enter fullscreen mode Exit fullscreen mode

With these set up, we can now go to our reducers file and import these three constants:

src/reducers/appReducers.js

import {
  INTERNET_ONLINE,
  INTERNET_OFFLINE,
  WS_CONNECTING,
  WS_OPENED,
  WS_CLOSED,
} from '../actions'

const initialState = {
  internet: {
    isOnline: true,
  },
  ws: {
    connecting: false,
    opened: false,
  },
}

const appReducer = (state = initialState, action) => {
  switch (action.type) {
    case INTERNET_ONLINE:
      return { ...state, internet: { ...state.internet, isOnline: true } }
    case INTERNET_OFFLINE:
      return { ...state, internet: { ...state.internet, isOnline: false } }
    default:
      return state
  }
}

export default appReducer
Enter fullscreen mode Exit fullscreen mode

It will go ahead and define the three constants in the switch statement to calculate the next state when called:

case WS_CONNECTING:
  return { ...state, ws: { ...state.ws, connecting: true } }
Enter fullscreen mode Exit fullscreen mode
case WS_OPENED:
  return { ...state, ws: { ...state.ws, connecting: false, opened: true } }
Enter fullscreen mode Exit fullscreen mode
case WS_CLOSED:
  return { ...state, ws: { ...state.ws, connecting: false, opened: false } }
Enter fullscreen mode Exit fullscreen mode
const appReducer = (state = initialState, action) => {
  switch (action.type) {
    case INTERNET_ONLINE:
      return { ...state, internet: { ...state.internet, isOnline: true } }
    case INTERNET_OFFLINE:
      return { ...state, internet: { ...state.internet, isOnline: false } }
    case WS_CONNECTING:
      return { ...state, ws: { ...state.ws, connecting: true } }
    case WS_OPENED:
      return { ...state, ws: { ...state.ws, connecting: false, opened: true } }
    case WS_CLOSED:
      return { ...state, ws: { ...state.ws, connecting: false, opened: false } }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

All is good! So far we went ahead and connected the UI with the reducer state, then we created the action creators that will help dispatch actions to the reducers. The reducers will pick up from there and calculate the next state so that the components can update.

What we need to do now is to instantiate a websocket client instance. However, it needs a websocket url to connect to. For the sake of this tutorial I provided a public one we can use:

const wsUrl = 'wss://echo.websocket.org'
Enter fullscreen mode Exit fullscreen mode

You will be able to create and leave a websocket connection opened by passing that URL to the constructor. We will go ahead and create a wsRef which will be assigned a useRef. This is where we will attach the websocket client instance with.

src/hooks/useWebsocket.js

import { useRef, useEffect } from 'react'
import { useSelector } from 'react-redux'

const wsUrl = 'wss://echo.websocket.org'

const useWebsocket = ({ isOnline }) => {
  const opened = useSelector((state) => state.app.ws.opened)

  const wsRef = useRef()

  // Initiates the websocket client on mount
  useEffect(() => {
    if (!wsRef.current) {
      wsRef.current = new WebSocket(wsUrl)
    }
  }, [])

  return {
    opened,
    ws: wsRef.current,
  }
}

export default useWebsocket
Enter fullscreen mode Exit fullscreen mode

We went ahead and created a useEffect which will automatically instantiate and attach a new websocket client on wsRef so that we can see the results on initial load.

For convenience, I provided a method for instantiating a websocket client and one for ending the currently opened websocket client:

const initWebsocket = () => {
  if (wsRef.current) {
    wsRef.current.close()
  }
  wsRef.current = new WebSocket(wsUrl)
}
Enter fullscreen mode Exit fullscreen mode
const endWebsocket = () => {
  if (wsRef.current) {
    wsRef.current.close()
  }
}
Enter fullscreen mode Exit fullscreen mode

Result:

src/hooks/useWebsocket.js

import { useRef, useEffect } from 'react'
import { useSelector } from 'react-redux'

const wsUrl = 'wss://echo.websocket.org'

const useWebsocket = ({ isOnline }) => {
  const opened = useSelector((state) => state.app.ws.opened)

  const wsRef = useRef()

  const initWebsocket = () => {
    if (wsRef.current) {
      wsRef.current.close()
    }
    wsRef.current = new WebSocket(wsUrl)
  }

  const endWebsocket = () => {
    if (wsRef.current) {
      wsRef.current.close()
    }
  }

  // Initiates the websocket client on mount
  useEffect(() => {
    if (!wsRef.current) {
      wsRef.current = new WebSocket(wsUrl)
    }
  }, [])

  return {
    ws: wsRef.current,
    opened,
    initWebsocket,
    endWebsocket,
  }
}

export default useWebsocket
Enter fullscreen mode Exit fullscreen mode

We're going to add two extra buttons to the UI so that we can support functionality where the user can manually open/close the websocket client (this feature is not really used in this tutorial, but it can easily be possible). One of them will be used to initiate a new websocket client instance when clicked, and the other will end it:

src/App.js

const { ws, opened, initWebsocket, endWebsocket } = useWebsocket({ isOnline })
Enter fullscreen mode Exit fullscreen mode
<div className='button-controls'>
  <button type='button' onClick={initWebsocket}>
    Initiate Websocket
  </button>
  <button type='button' onClick={endWebsocket}>
    End Websocket
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Great!

But wait. We created a way for the components to update, but they need a place and time to be updated from.

We'll go back to our initWebsocket function and attach some event listeners to the open and close events:

src/hooks/useWebsocket.js

const initWebsocket = () => {
  if (wsRef.current) wsRef.current.close()
  wsRef.current = new WebSocket(wsUrl)
  wsRef.current.addEventListener('message', () => {})
  wsRef.current.addEventListener('open', () => {})
  wsRef.current.addEventListener('close', () => {})
  wsRef.current.addEventListener('error', () => {})
}
Enter fullscreen mode Exit fullscreen mode

Remember that a websocket connection can have four different listeners:

Listener Description
onclose Called when the WebSocket connection's readyState changes to CLOSED
onmessage Called when a message is received from the server
onopen Called when the WebSocket connection's readyState changes to OPEN
onerror Called when an error occurs on the WebSocket
     |
Enter fullscreen mode Exit fullscreen mode

With this in place we can now look forward to attach some handlers:

const onMessage = (msg) => {
  console.log(msg)
}

const onOpen = () => {
  console.log('WS client opened')
}

const onClose = () => {
  console.log('WS client closed')
}

const onError = () => {
  console.log('WS client errored')
}

const initWebsocket = () => {
  if (wsRef.current) wsRef.current.close()
  wsRef.current = new WebSocket(wsUrl)
  wsRef.current.addEventListener('message', onMessage)
  wsRef.current.addEventListener('open', onOpen)
  wsRef.current.addEventListener('close', onClose)
  wsRef.current.addEventListener('error', onError)
}
Enter fullscreen mode Exit fullscreen mode

Currently our useEffect is attaching a new websocket client instance on wsRef.current but it's now missing the implementation of registering event handlers. So we need to do a quick update to make it invoke the handler that does the registering instead:

src/hooks/useWebsocket.js

// Initiates the websocket client on mount
useEffect(() => {
  if (!wsRef.current) initWebsocket()
}, [initWebsocket])
Enter fullscreen mode Exit fullscreen mode

Also, since we registered the event listeners in the initiator handler, we also need to make sure that they get removed when the client is closed, to avoid a memory leak:

src/hooks/useWebsocket.js

const endWebsocket = () => {
  if (wsRef.current) {
    wsRef.current.removeEventListener('message', onMessage)
    wsRef.current.removeEventListener('open', onOpen)
    wsRef.current.removeEventListener('close', onClose)
    wsRef.current.removeEventListener('error', onError)
    wsRef.current.close()
  }
}
Enter fullscreen mode Exit fullscreen mode

From the beginning, our goal was to make the websocket in sync with the user's internet connectivity. Looking at what we got so far in our code, we now have an API set up to allow us to approach that functionality.

When the the user's internet goes offline, our websocket close event handler must be invoked somehow.

The thing is, the websocket client is not in sync with the internet connection. I've tested this on linux and windows machines and when the internet goes off the websocket client's readyState property can still be stuck at 1 (the value for the client's OPEN state). So we cannot rely on it to close by itself.

For a quick reverence of the different readyState's' you can either click the link above or have a look at this table:

Remember that a websocket can go through four different states throughout the lifetime of their connection:

Value State Description
0 CONNECTING Socket has been created. The connection is not yet open.
1 OPEN The connection is open and ready to communicate.
2 CLOSING The connection is in the process of closing.
3 CLOSED The connection is closed or couldn't be opened.

If the internet goes offline then we need to invoke the websocket client's close handler immediatel. When it comes back online we need to do the same for the open handler, otherwise the websocket client will show that the user is still connected even though his internet is disconnected. Very misleading! We should fix that.

Going back to the action creators we created earlier, we can utilize them to send a signal to our reducers:

src/actions/index.js

export const wsConnecting = () => ({
  type: WS_CONNECTING,
})

export const wsOpened = () => ({
  type: WS_OPENED,
})

export const wsClosed = () => ({
  type: WS_CLOSED,
})
Enter fullscreen mode Exit fullscreen mode

And here is the final update to our reducers:

src/reducers/appReducers.js

import {
  INTERNET_ONLINE,
  INTERNET_OFFLINE,
  WS_CONNECTING,
  WS_OPENED,
  WS_CLOSED,
} from '../actions'

const initialState = {
  internet: {
    isOnline: true,
  },
  ws: {
    connecting: false,
    opened: false,
  },
}

const appReducer = (state = initialState, action) => {
  switch (action.type) {
    case INTERNET_ONLINE:
      return { ...state, internet: { ...state.internet, isOnline: true } }
    case INTERNET_OFFLINE:
      return { ...state, internet: { ...state.internet, isOnline: false } }
    case WS_CONNECTING:
      return { ...state, ws: { ...state.ws, connecting: true } }
    case WS_OPENED:
      return { ...state, ws: { ...state.ws, connecting: false, opened: true } }
    case WS_CLOSED:
      return { ...state, ws: { ...state.ws, connecting: false, opened: false } }
    default:
      return state
  }
}

export default appReducer
Enter fullscreen mode Exit fullscreen mode

Our reducers are all hooked up with the action creators and now they should be updating. Our next step is to make the UI components update. We will modify the useWebsocket hook to invoke some handlers so that our UI component will just update--not having to worry about any logic. Doing so will make our code much easier to read and maintain in the future.

Inside our hook we are going to make another useEffect which will invoke everytime the value of isOnline changes. When isOnline changes to false, we'll go ahead and dispatch the wsClosed action. When it changes to true we will dispatch the wsOpened action. Doing so will make all of the components that are attached to the state update accordingly to the changes.

src/hooks/useWebsocket.js

// Responsible for updating redux when isOnline changes
useEffect(() => {
  if (isOnline && !opened) {
    dispatchAction(wsOpened())
  } else if (!isOnline && opened) {
    dispatchAction(wsClosed())
  }
}, [isOnline, dispatchAction, opened])
Enter fullscreen mode Exit fullscreen mode

In addition, we also need to dispatch the wsClosed action in the endWebsocket function to help the *useEffect*s to stay in sync when there are changes to the user's internet connection:

src/hooks/useWebsocket.js

const endWebsocket = () => {
  if (wsRef.current) {
    wsRef.current.removeEventListener('message', onMessage)
    wsRef.current.removeEventListener('open', onOpen)
    wsRef.current.removeEventListener('close', onClose)
    wsRef.current.removeEventListener('error', onError)
    wsRef.current.close()
    if (opened) dispatchAction(wsClosed())
  }
}
Enter fullscreen mode Exit fullscreen mode

Our state in redux should now update and attempt to keep the websocket client in sync. However, the websocket client won't just close automatically yet. We need to make it close by invoking the close method in the onClose handler:

src/hooks/useWebsocket.js

import { useDispatch, useSelector } from 'react-redux'
import { wsOpened, wsClosed } from '../actions'
Enter fullscreen mode Exit fullscreen mode
const dispatchAction = useDispatch()
Enter fullscreen mode Exit fullscreen mode
const onOpen = (e) => {
  console.log('WS client opened')
}
Enter fullscreen mode Exit fullscreen mode
const onClose = (e) => {
  console.log('WS client closed')
  if (wsRef.current) {
    wsRef.current.close()
  }
}
Enter fullscreen mode Exit fullscreen mode

Since we attached the action creators to the open and close event handlers for the websocket client, we can go ahead and just import the hook into a component now:

import React, { useEffect } from 'react'
import useInternet from './hooks/useInternet'
import useWebsocket from './hooks/useWebsocket'
import WebsocketConnection from './components/WebsocketConnection'
import './App.css'

const App = () => {
  const { isOnline } = useInternet()
  const { ws, opened, initWebsocket, endWebsocket } = useWebsocket({ isOnline })

  useEffect(() => {
    console.log(
      `%cYou are ${isOnline ? 'online' : 'offline'}.`,
      `color:${isOnline ? 'green' : 'red'}`,
    )
  }, [isOnline])

  return (
    <div>
      <h1>Making Websocket in Sync with the User's Internet Connectivity</h1>
      <hr />
      <WebsocketConnection opened={opened} />
      <div className='button-controls'>
        <button type='button' onClick={initWebsocket}>
          Initiate Websocket
        </button>
        <button type='button' onClick={endWebsocket}>
          End Websocket
        </button>
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

And voila! Try disconnecting your internet and see the results:

Online state:

2

Next, I disconnected the internet:

3

Connected back to the internet:

4

Conclusion

And that concludes the end of this series!

Top comments (0)