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));
};
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));
};
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;
}
};
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));
};
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'));
};
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; }[] = [];
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));
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));
/**
* 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 })
};
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);
};
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>
)
Lower level component
const storage = useContext(StorageContext);
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);
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!
❤️🐘🐘🧠❤️🐘🧠💕🦄🧠🐘
Top comments (4)
Save all data using JSON.stringify, and when you will read them use always JSON.parse that convert data to the original type.
You can use it not only on an object, also on a single value (number, bool, string...).
Nice
That's really cool, lovely exposed APIs.
Thank you so much 😊
I was working with socket io client at the time so the syntax is inspired by that 😅