diceline-chartmagnifierquestion-marktwitter-whiteTwitter_Logo_Blue

Today I Learned

How to obtain reactivity in custom hooks while interacting with the local storage

This is how we can obtain reactivity in our custom React.js hooks while working with the local storage, using the Pub/Sub (Observer) design pattern (with TypeScript support).

The goal is to implement a "useLocalStorage" custom hook, which will abstract away the complexity of reading from and writing to the local storage. As we know, each custom hook instantiates its own state. That is a problem in our case because when one instance of the hook updates the local storage, the state copies held by all the other hook instances will be out of sync and will never be updated.

We can solve this issue using the following idea: we can mimic a centralized shared state between our custom hook instances by delegating the responsibility of holding these in sync with the local storage to a custom "manager", the Observer object.

Our custom hook will work based on these ideas:

  1. Custom Hook instances will subscribe their inner state updater functions to this manager
  2. Custom Hook instances will publish the new state to the manager when updating a key of the local storage
  3. The manager will trigger all subscriber functions and thus update the inner states of the custom hook instances.

The observer object:

export type Listener<EventType> = (event: EventType) => void;

export type ObserverReturnType<KeyType, EventType> = {
  subscribe: (entryKey: KeyType, listener: Listener<EventType>) => () => void;
  publish: (entryKey: KeyType, event: EventType) => void;
};

export default function createObserver<
  KeyType extends string | number | symbol,
  EventType,
>(): ObserverReturnType<KeyType, EventType> {
  const listeners: Record<KeyType, Listener<EventType>[]> = {} as Record<
    KeyType,
    Listener<EventType>[]
  >;

  return {
    subscribe: (entryKey: KeyType, listener: Listener<EventType>) => {
      if (!listeners[entryKey]) listeners[entryKey] = [];
      listeners[entryKey].push(listener);
      return () => {
        listeners[entryKey].splice(listeners[entryKey].indexOf(listener), 1);
      };
    },
    publish: (entryKey: KeyType, event: EventType) => {
      if (!listeners[entryKey]) listeners[entryKey] = [];
      listeners[entryKey].forEach((listener: Listener<EventType>) =>
        listener(event),
      );
    },
  };
}

export const LocalStorageObserver = createObserver<
  LOCAL_STORAGE_KEYS,
  string
>();

export const { subscribe, publish } = LocalStorageObserver;

The useLocalStorage custom hook (window checks are optional, depending on which environment this JavaScript will run on):

export function useLocalStorage<T>(key: LOCAL_STORAGE_KEYS, initialValue: T) {
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  LocalStorageObserver.subscribe(key, setStoredValue);

  const setValue = (value: T) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      LocalStorageObserver.publish(key, valueToStore);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(error);
    }
  };
  return [storedValue, setValue];
}

Axios Request and Response Interceptors

Axios interceptors come in handy when we need to track, register, or work with:

  1. Requests before leaving
  2. Responses before arriving
  3. Both 1 and 2

Thanks to interceptors, we can pass our own handlers/callbacks for the following cases:

  1. Before launching a request
  2. Catching an error at HTTP request launch
  3. Arrival of a response
  4. Catching an error at response arrival

This is an example of how we can use them:

// handlers for the request launch and for catching request launch error
axios.interceptors.request.use(
   (config) => {
      console.log('We are now preparing to launch the request!')
      return config;
   },
   (error) => Promise.reject(error),
);

// handlers for response interceptor and error response interceptor
axios.interceptors.response.use(
     (response) => {
        console.log('We received the response!');
        return response;
     },
     (error) => {
       if (error.response.status === 403) 
         return Promise.reject(error);
     }
},

Each Axios Instance can have its custom configuration and request and response interceptors. This can be very useful when the architecture of our application enforces/allows each of our services to use their own Axios Instance. This way, each service can have an Axios Instance with custom configuration and custom request/response interceptors.

This is how an Axios Instance factory utility function could look like in TypeScript:

export const createAxiosWithInterceptors = (
  requestConfig: AxiosRequestConfig = {},
  requestInterceptorHandlers: Partial<RequestInterceptorHandlers> = DefaultRequestInterceptor,
  responseInterceptorHandlers: Partial<ResponseInterceptorHandlers> = DefaultResponseInterceptor,
) => {
  const axiosInstance = axios.create(requestConfig);

  axiosInstance.interceptors.request.use(
    requestInterceptorHandlers.requestConfigHandler,
    requestInterceptorHandlers.requestErrorHandler,
  );

  axiosInstance.interceptors.response.use(
    responseInterceptorHandlers.responseHandler,
    responseInterceptorHandlers.responseErrorHandler,
  );

  return axiosInstance;
};

where we can define our types and default interceptors as follows:

type RequestConfigHandler = (config: AxiosRequestConfig) => AxiosRequestConfig;
type RequestErrorHandler = (error: AxiosError) => Promise<AxiosError>;
type RequestInterceptorHandlers = {
  requestConfigHandler: RequestConfigHandler;
  requestErrorHandler: RequestErrorHandler;
};

type ResponseHandler = (response: AxiosResponse) => AxiosResponse;
type ResponseErrorHandler = (error: AxiosError) => Promise<AxiosError>;

type ResponseInterceptorHandlers = {
  responseHandler: ResponseHandler;
  responseErrorHandler: ResponseErrorHandler;
};

const DefaultResponseInterceptor = {
  responseHandler: (response: AxiosResponse) => response,
  responseErrorHandler: (error: AxiosError) => Promise.reject(error),
};

const DefaultRequestInterceptor = {
  requestConfigHandler: (config: AxiosRequestConfig) => config,
  requestErrorHandler: (error: AxiosError) => Promise.reject(error),
};

In each of our services we can then use our factory method as follows:

// axios instance with custom request config received through constructor, custom request interceptor and default response interceptor
protected http: AxiosInstance = createAxiosWithInterceptors(
    this.requestConfig,
    {
      requestConfigHandler: (config) => {
        console.log();
        return config;
      },
    },
);

How to extend interfaces declared in external libraries in Typescript

Typescript allows us to easily extend types by using module augumentation.

Let's take a look at one quick example - extending the React Material Ui Library Theme.

All we need to do is create a file ending in .d.ts at the root of our Typescript project - in this case I'll name it material-ui.d.ts:

import {
  Theme as MuiTheme,
} from '@mui/material/styles';

declare module '@mui/material/styles' {
  export interface Theme extends MuiTheme {
    customization?: Record<string, string>;
  }
}

How to declare path aliases in Typescript

Defining path aliases using webpack can save you a lot of headache when it comes to imports, but you must also let Typescript know about them.

Following my previous post on declaring path aliases using webpack, you can configure your tsconfig.json file to in order to be able to use those aliases in Typescript like so:

{
  ...
  "paths": {
    "@/*": ["./src/*"],
    "images/*": ["./assets/images/*"],
  },
  "include": [
    ...
  ],
}

Of course, all paths for defined aliases must be reachable by Typescript. You can check this post out if you are not sure how to do that.

Otherwise, we are going to get this error:

Cannot find module 'images/[your module]' or its corresponding type declarations.

How to import images in Typescript

Normally, when Typescript cannot find something we get this error:

Cannot find module [your module] or its corresponding type declarations ts(2307)

Now let's see how we can fix this, with a simple example. Given the following folder structure:

│── src
│   ├── resources
│   │   ├── ts
│   │   │   ├── **/*.ts
│   │   ├── images
│   │   │   ├── logo.svg
├── ...
├── tsconfig.json

In order to be able to achieve something like this:

import Logo from '[path]/images/logo.svg'

without any ** Typescript errors**, we need to follow these steps:

1.Make sure that the images folder is "reachable" by Typescript.

Adding this inside tsconfig.json will do the trick:

{
  "include": [
        ...
        "resources/**/*.ts",
   ],
}

A configuration like this one will not do:

{
  "include": [
      "resources/ts/**/*.ts",
   ],
}

Because images/ is not included in ts/ and we don't have any other folders declared, so Typescript can't "reach" it.

2.Let Typescript know about the .svg type in that folder.

In the root of the images folder create a file called index.d.ts:

...
├── resources
│   ├── ts
│   │   ├── **/*.ts
│   ├── images
│   │   ├── logo.svg
│   │   ├── index.d.ts
...

With the following contents:

declare module '*.svg' {
  const value: any;
  export = value;
}

Now you should be good to go.