How to interact with non React code


1

An abstract image

Introduction

In the world of web development, it's not uncommon to find yourself needing to integrate React, a popular JavaScript library for building user interfaces, with non-React code. This could be due to a variety of reasons such as working with legacy code, using third-party libraries that aren't built with React, or simply needing to interact with the raw DOM API or other technologies like WebSockets...

In this blog post. We'll learn how to create a external store, what they do, why they're useful, and how to get the most out of them 🤓.

Let's get started!

A external store

A external store is a JavaScript object that stores data and provides methods for reading and updating that data. It's a simple concept, but it can be used to solve some interesting problems 🤔.

Alright, let's start with the following setup:

export function createStore(reducer, initialState) {
let state = initialState;
let listeners = [];

const emitChange = () => {
listeners.forEach((l) => l());
}

const getState = () => state;

const dispatch = (action) => {
state = reducer(state, action);

emitChange();
};

const subscribe = (listener) => {
listeners.push(listener);

return () => {
listeners = listeners.filter((l) => l !== listener);
};
};

const store = {
dispatch,
getState,
subscribe,
};

return store;
}

It's relatively short 👀, but there's a lot of stuff packed into this small function, let's break it down:

  1. Store state

The first thing is store's state. This is where we'll store all of the data that our store needs to keep track of, and a function called getState that returns the current state of the store.

let state = initialState;

const getState = () => state;
  1. Listeners

Next, we create a variable called listeners that will hold an array of functions. These functions are called whenever the store's state changes, and they're responsible for updating any components that are subscribed to the store.

let listeners = [];

const emitChange = () => {
listeners.forEach((l) => l());
}
  1. Dispatch

A dispatch function takes an action as an argument. This function is responsible for updating the store's state based on the action that was passed in. It does this by calling the reducer function that was passed into the createStore function when it was created and update any components that are subscribed to the store by calling the emitChange function.

const dispatch = (action) => {
state = reducer(state, action);

emitChange();
};
  1. Subscribe

The last thing we do is create a subscribe function that takes a listener as an argument. This function is responsible for adding the listener to the listeners array and returning a function that can be used to remove the listener from the array.

const subscribe = (listener) => {
listeners.push(listener);

return () => {
listeners = listeners.filter((l) => l !== listener);
};
};

You may find this a bit similar to the Redux Application Data flow:

Redux Application Data flow

Gif from redux

This often referred to as the Pub-Sub pattern, is a popular design pattern in JavaScript for managing and decoupling code in an application. It promotes loose coupling by establishing a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. This is achieved by publishers who send out notifications or events, and subscribers who listen for these events and react accordingly 🤯.

Use cases

Now that we have a basic understanding of what a external store is and how it works, let's look at some use cases where it can be useful 😄.

Sharing data different parts of application

One of the most common use cases for a external store is sharing data between different parts of an application, allow us to interact with some third-party libraries that hold state outside of React or browser API

For example, In axios library, we can use axios.interceptors to intercept requests or responses before they are handled by then or catch.

// Add a request interceptor
axios.interceptors.request.use(function (config) {
// Do something before request is sent
return config;
}, function (error) {
// Do something with request error
return Promise.reject(error);
});

// Add a response interceptor
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
});

In most of projects I've worked on, it use an authentication token to authenticate requests, so by creating an instance of axios with some custom configs along with interceptors, and then export it to use in other parts of the application, i can easily handle once the token is expired or invalid by using the refresh token to get a new one.

api.interceptors.response.use(
(response) => {
return response.data;
},
async (error) => {
const resError = error.response;
const dataError = resError?.data;

switch (resError?.status) {
case 500:
return errorCallback(500, dataError?.message);

case 401: {
// Handle if token is refreshing
// ...
}
default:
return errorCallback(500, dataError?.message);
}
}
);

But the refresh token itself has many security issues, so many projects only provide a short-lived token, and when it expires, the user must log in again and thus lose all the data they have entered, creating a bad user experience.

So, how can we handle this case? 🤔

We can show a popup to ask user to login again while keep their current page in the background, and once they login successfully, retrying all the failed request. But the axios instance is created outside of React, how can we show a popup that is a React component and handling all the login logic?

This is where a external store comes in handy. We can use it to store a state that control the popup visibility and dispatch an action to toggle it when the token is expired.

const initialState = { isOpen: false };

function reducer(state, action) {
switch (action.type) {
case 'TOGGLE_MODAL':
return {
...state,
isOpen: !state.isOpen,
};

default:
return state;
}
}

export const store = createStore(reducer, initialState);

And then we can use it in our React component like this:

import { store } from './store';

export const App = () => {
const [open, setOpen] = useState(false);

useEffect(() => {
const listener = () => {
setState(store.getState());
};

const unsubscribe = store.subscribe(listener);

// Clean up the subscription when the component unmounts
return () => {
unsubscribe();
};
}, [getState, subscribe]);

return (
<Modal isOpen={open}>
{/* Login form */}
</Modal>
)
}

Update our interceptor accordingly:

api.interceptors.response.use(
(response) => {
return response.data;
},
async (error) => {
const resError = error.response;
const dataError = resError?.data;

switch (resError?.status) {
case 500:
return errorCallback(500, dataError?.message);

case 401: {

store.dispatch({ type: 'TOGGLE_MODAL' });

const token = await new Promise(() => {
// Get token once user login successfully
})

// Retry all the failed request with the new token
// ...

return Promise.reject(error);
}
default:
return errorCallback(500, dataError?.message);
}
}
);

By using this pattern, we can easily handle the case where the token is expired without losing any data that the user has entered.

Appendix: Tweaks

There are a few more small tweaks and optimizations I've made to the solution. Let's talk about them!

  1. Custom hook

In our example, we used useEffect to subscribe to the store and update the component's state whenever the store's state changes. This works fine, but it's not very elegant. We can do better by creating a custom hook that handles this for us.


import { useEffect, useState } from 'react';

function useSyncStore({ subscribe, getState }) {
const [state, setState] = useState(getState);

useEffect(() => {
const listener = () => {
setState(getState());
};
const unsubscribe = subscribe(listener);
// Clean up the subscription when the component unmounts
return () => {
unsubscribe();
};
}, [getState, subscribe]);

return state;
}

export default useSyncStore;

And then we can use it in our component like this:

const state = useSyncStore(store);
  1. Store actions

When we need a dispatch an action, we have to call store.dispatch and pass in the action type and payload. But we can create an object that contains all of our store's actions and then export it so that we can use it in other parts of our application.

import { store } from './store';

export const action = {
toggle() {
store.dispatch({ type: 'TOGGLE_MODAL' });
},
};

This makes it easier to dispatch actions without having to import the store directly.

Nhãn dán  Usagi, a pink rabbit, stands proudly behind Piske, a white bird, while they both flex their arms and wear a determined expression.

Conclusion

In this article, we've learned how to create a external store, what they do, why they're useful. We also looked at some use cases where they can be helpful in solving problems that would otherwise be difficult or impossible to solve without them.

I hope you found this article helpful! If you have any questions or feedback, please reach out to me on social media.

Happy reading! 🍻