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];
}