React: Custom hook for accessing storage

Andrew Bone - Apr 21 '21 - - Dev Community

It's been 8 months since I've written anything in this series and I'm sure my coding style has changed a lot in that time, for instance for hooks I now use typescript which, though felt scary moving to, has sped up development because it catches every mistake I make.

Recently I needed to use web storage but annoyingly discovered there wasn't an event listener I could use from other parts of my apps to listen for changes. I was using react so had a choice, pass all the data in props and only change storage content from the top level or write something to do what I wanted. I went for the latter.

What I wanted to achieve

The outcome I was aiming for was to have a set of functions I could throw data at and they'd store it nicely but also fire 'events' that I could listen for elsewhere in app. I settled on these 9 functions; init, set, get, remove, clear, on, onAny, off, offAny. I'll briefly go over each one and what it does.

init

init takes a key and some data. The key is a string and is the identifier used in the storage table we'll need it for getting data out of storage too. Data can be of any type but will be stored as a string then returned in its original form.

As you can see we get the typeof the data and store that in a key which we can look up later. We also look at onList and onAnyList and run their callbacks but more on those later.

/**
 * Set the data, generally this should be an empty version of the data type
 * 
 * @param key key to be used in the storage table
 * @param data data to be passed in to the storage table as the value
 * 
 * @example storage.init('table_name', [])
 * 
 * @event `init` the key is passed through
 */
const init = (key: string, data: any) => {
  const type = typeof data;
  if (type === "object") {
    data = JSON.stringify(data);
  }
  storageType.setItem(key, data);
  storageType.setItem(`$$${key}_data`, type);
  onList.filter(obj => obj.type === 'init').forEach(obj => obj.callback(key));
  onAnyList.forEach(obj => obj.callback('init', key));
};
Enter fullscreen mode Exit fullscreen mode

set

set is basically the exact same function as init but triggers a different event.

/**
 * Set the data, generally you will need to get the data modify it then set it.
 * 
 * @param key key to be used in the storage table
 * @param data data to be passed in to the storage table as the value
 * 
 * @example storage.set('table_name', ['item1','item2'])
 * 
 * @event `set` the key is passed through
 */
const set = (key: string, data: any) => {
  const type = typeof data;
  if (type === "object") {
    data = JSON.stringify(data);
  }
  storageType.setItem(key, data);
  storageType.setItem(`$$${key}_data`, type);
  onList.filter(obj => obj.type === 'set').forEach(obj => obj.callback(key));
  onAnyList.forEach(obj => obj.callback('set', key));
};
Enter fullscreen mode Exit fullscreen mode

get

get simply gets the data, looks at what type we said it was when we stored it and converts it back, as I mentioned earlier everything is stored as a string, we still trigger an event with get but I can't imagine many people using that one.

/**
 * Get the data.
 * 
 * @param key key to be fetched from the storage table
 * 
 * @example const tableName = storage.get('table_name');
 * 
 * @event `get` the key is passed through
 * 
 * @returns contents of selected key
 */
const get = (key: string) => {
  const type = storageType.getItem(`$$${key}_data`);
  const data = storageType.getItem(key);

  onList.filter(obj => obj.type === 'get').forEach(obj => obj.callback(key));
  onAnyList.forEach(obj => obj.callback('get', key));

  switch (type) {
    case "object":
      return JSON.parse(data);
    case "number":
      return parseFloat(data);
    case "boolean":
      return data === 'true';
    case "undefined":
      return undefined;
    default:
      return data;
  }
};
Enter fullscreen mode Exit fullscreen mode

remove

remove takes a key and removes it and its type field from storage this is useful if you're tiding up as you go.

/**
 * Remove a specific key and its contents.
 * 
 * @param key key to be cleared from the storage table
 * 
 * @example storage.remove('table_name');
 * 
 * @event `remove` the key is passed through
 */
const remove = (key: string) => {
  storageType.removeItem(key);
  storageType.removeItem(`$$${key}_data`);
  onList.filter(obj => obj.type === 'remove').forEach(obj => obj.callback(key));
  onAnyList.forEach(obj => obj.callback('remove', key));
};
Enter fullscreen mode Exit fullscreen mode

clear

clear removes all items from storage, useful for when a user logs off and you want to clear all their data.

/**
 * Remove all items from storage
 * 
 * @example storage.clear();
 * 
 * @event `clear` the key is passed through
 */
const clear = () => {
  storageType.clear();
  onList.filter(obj => obj.type === 'clear').forEach(obj => obj.callback());
  onAnyList.forEach(obj => obj.callback('clear'));
};
Enter fullscreen mode Exit fullscreen mode

Event Listeners

The next four functions are all related to how I'm doing events so I've bundled them all up here.

Basically I store an array of objects, one that contains a type and callback and one that just has callbacks.

const onList: { type: string; callback: Function; }[] = [];
const onAnyList: { callback: Function; }[] = [];
Enter fullscreen mode Exit fullscreen mode

Adding event

When we use on it's added to onList then, as you may have noticed in earlier functions, we filter the array based on items that match by type then run all the callbacks.

onList.filter(obj => obj.type === 'set').forEach(obj => obj.callback(key));
Enter fullscreen mode Exit fullscreen mode

We also have onAny this is an event listener that doesn't care what the event it and will trigger no matter what we do, the callback does know what the event was though.

onAnyList.forEach(obj => obj.callback('set', key));
Enter fullscreen mode Exit fullscreen mode
/**
 * Add event listener for when this component is used.
 * 
 * @param event name of event triggered by function
 * @param func a callback function to be called when event matches
 * 
 * @example storage.on('set', (key) => {
 *   const data = storage.get(key);
 *   console.log(data)
 * })
 */
const on = (event: string, func: Function) => {
  onList.push({ type: event, callback: func })
};

/**
 * Add event listener, for all events, for when this component is used.
 * 
 * @param func a callback function to be called when any event is triggered
 * 
 * @example storage.onAny((key) => {
 *   const data = storage.get(key);
 *   console.log(data)
 * })
 */
const onAny = (func: Function) => {
  onAnyList.push({ callback: func })
};
Enter fullscreen mode Exit fullscreen mode

Removing Event

To remove an event you simply pass in the type and callback, or just callback in the case of an any, and it will remove it from the array.

/**
 * If you exactly match an `on` event you can remove it
 * 
 * @param event matching event name
 * @param func matching function
 */
const off = (event: string, func: Function) => {
  const remove = onList.indexOf(onList.filter(e => e.type === event && e.callback === func)[0]);
  if (remove >= 0) onList.splice(remove, 1);
};

/**
 * If you exactly match an `onAny` function you can remove it
 * 
 * @param func matching function
 */
const offAny = (func: Function) => {
  const remove = onAnyList.indexOf(onAnyList.filter(e => e.callback === func)[0]);
  if (remove >= 0) onAnyList.splice(remove, 1);
};
Enter fullscreen mode Exit fullscreen mode

Using context

The way we access this will be with createContext meaning we initialise it at the top level and then wrap our code with a provider allowing use to access the functions from anywhere.

Top level

const storage = useLocalStorage('session');

return (
  <StorageContext.Provider value={storage}>
    <App />
  </StorageContext.Provider>
)
Enter fullscreen mode Exit fullscreen mode

Lower level component

const storage = useContext(StorageContext);
Enter fullscreen mode Exit fullscreen mode

Putting it all together

Putting it all together we need a way to say whether we're using local or session storage and we need to make sure our functions aren't reset on every redraw. So this was how it looked as one big lump, I've documented it but feel free to ask in the comments.

import { createContext, useMemo, useState } from 'react';

const onList: { type: string; callback: Function; }[] = [];
const onAnyList: { callback: Function; }[] = [];

interface Storage {
  setItem: Function,
  getItem: Function,
  removeItem: Function,
  clear: Function
}

/**
 * A hook to allow getting and setting items to storage, hook comes 
 * with context and also event listener like functionality
 * 
 * @param type either local or session
 * 
 * @example 
 * const storage = useLocalStorage('session');
 * <StorageContext.Provider value={storage}>...</StorageContext.Provider>
 */
export default function useLocalStorage(type: "local" | "session") {
  const [storageType] = useState<Storage>((window as any)[`${type}Storage`]);

  // Prevent rerun on parent redraw
  return useMemo(() => {
    /**
     * Set the data, generally this should be an empty version of the data type
     * 
     * @param key key to be used in the storage table
     * @param data data to be passed in to the storage table as the value
     * 
     * @example storage.init('table_name', [])
     * 
     * @event `init` the key is passed through
     */
    const init = (key: string, data: any) => {
      const type = typeof data;
      if (type === "object") {
        data = JSON.stringify(data);
      }
      storageType.setItem(key, data);
      storageType.setItem(`$$${key}_data`, type);
      onList.filter(obj => obj.type === 'init').forEach(obj => obj.callback(key));
      onAnyList.forEach(obj => obj.callback('init', key));
    };

    /**
     * Set the data, generally you will need to get the data modify it then set it.
     * 
     * @param key key to be used in the storage table
     * @param data data to be passed in to the storage table as the value
     * 
     * @example storage.set('table_name', ['item1','item2'])
     * 
     * @event `set` the key is passed through
     */
    const set = (key: string, data: any) => {
      const type = typeof data;
      if (type === "object") {
        data = JSON.stringify(data);
      }
      storageType.setItem(key, data);
      storageType.setItem(`$$${key}_data`, type);
      onList.filter(obj => obj.type === 'set').forEach(obj => obj.callback(key));
      onAnyList.forEach(obj => obj.callback('set', key));
    };

    /**
     * Get the data.
     * 
     * @param key key to be fetched from the storage table
     * 
     * @example const tableName = storage.get('table_name');
     * 
     * @event `get` the key is passed through
     * 
     * @returns contents of selected key
     */
    const get = (key: string) => {
      const type = storageType.getItem(`$$${key}_data`);
      const data = storageType.getItem(key);

      onList.filter(obj => obj.type === 'get').forEach(obj => obj.callback(key));
      onAnyList.forEach(obj => obj.callback('get', key));

      switch (type) {
        case "object":
          return JSON.parse(data);
        case "number":
          return parseFloat(data);
        case "boolean":
          return data === 'true';
        case "undefined":
          return undefined;
        default:
          return data;
      }
    };

    /**
     * Remove a specific key and its contents.
     * 
     * @param key key to be cleared from the storage table
     * 
     * @example storage.remove('table_name');
     * 
     * @event `remove` the key is passed through
     */
    const remove = (key: string) => {
      storageType.removeItem(key);
      storageType.removeItem(`$$${key}_data`);
      onList.filter(obj => obj.type === 'remove').forEach(obj => obj.callback(key));
      onAnyList.forEach(obj => obj.callback('remove', key));
    };

    /**
     * Remove all items from storage
     * 
     * @example storage.clear();
     * 
     * @event `clear` the key is passed through
     */
    const clear = () => {
      storageType.clear();
      onList.filter(obj => obj.type === 'clear').forEach(obj => obj.callback());
      onAnyList.forEach(obj => obj.callback('clear'));
    };

    /**
     * Add event listener for when this component is used.
     * 
     * @param event name of event triggered by function
     * @param func a callback function to be called when event matches
     * 
     * @example storage.on('set', (key) => {
     *   const data = storage.get(key);
     *   console.log(data)
     * })
     */
    const on = (event: string, func: Function) => {
      onList.push({ type: event, callback: func })
    };

    /**
     * Add event listener, for all events, for when this component is used.
     * 
     * @param func a callback function to be called when any event is triggered
     * 
     * @example storage.onAny((key) => {
     *   const data = storage.get(key);
     *   console.log(data)
     * })
     */
    const onAny = (func: Function) => {
      onAnyList.push({ callback: func })
    };

    /**
     * If you exactly match an `on` event you can remove it
     * 
     * @param event matching event name
     * @param func matching function
     */
    const off = (event: string, func: Function) => {
      const remove = onList.indexOf(onList.filter(e => e.type === event && e.callback === func)[0]);
      if (remove >= 0) onList.splice(remove, 1);
    };

    /**
     * If you exactly match an `onAny` function you can remove it
     * 
     * @param func matching function
     */
    const offAny = (func: Function) => {
      const remove = onAnyList.indexOf(onAnyList.filter(e => e.callback === func)[0]);
      if (remove >= 0) onAnyList.splice(remove, 1);
    };

    return { init, set, get, remove, clear, on, onAny, off, offAny }
  }, [storageType]);
};

export const StorageContext = createContext(null);
Enter fullscreen mode Exit fullscreen mode

Examples

In this example we have 2 components an add component for adding new items and a list component for showing items in the list.

Because embedding doesn't play too nice with storage, I'll link you to codesandbox for the example.

Using the magic of context and storage the list persists between visits and the two components don't have to know about the others existence.

Wrapping up

Well that was a lot of stuff, I hope someone out there finds this helpful, it was certainly a fun challenge to try and solve. As always I encourage you to ask questions or tell me what I could be doing better down below.

Thanks for reading!
❤️🐘🐘🧠❤️🐘🧠💕🦄🧠🐘

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .