In the last episode of the Custom React Hooks series, we've implemented the useArray hook to simplify arrays management. In today's episode, we'll create a hook to simplify the local storage management: useLocalStorage
.
Motivation
In the first place, let's see why would you need to implement this hook. Imagine that you're building an application that uses some config for each user (theme, language, settings...). To save the config, you'll use an object that could look like this:
const config = {
theme: 'dark',
lang: 'fr',
settings: {
pushNotifications: true
}
}
Now, in the root component or in the settings page, you would let the user customize its settings, in which case you will need to synchronize the UI state with the local storage. For instance, the settings page could look like this:
And the corresponding source code could be similar to this:
const defaultConfig = {
theme: 'dark',
lang: 'fr',
settings: {
pushNotifications: true
}
};
const Settings = () => {
const [config, setConfig] = useState(() => {
const saved = localStorage.getItem('config');
if (saved !== null) {
return JSON.parse(saved);
}
return defaultConfig;
});
const handleChange = (e) => {
setConfig(oldConfig => {
const newConfig = {
...oldConfig,
settings: {
...oldConfig.settings,
pushNotifications: e.target.checked
}
};
localStorage.setItem('config', JSON.stringify(newConfig));
return newConfig;
})
}
return (
<>
<h1>Settings</h1>
<label htmlFor="pushNotifications">
Push Notifications
</label>
<input
type="checkbox"
id="pushNotifications"
checked={config.settings.pushNotifications}
onChange={handleChange}
/>
</>
);
};
But as you can see... that's already a lot of code for just toggling a push notifications setting! Also, we have to manually synchronize the state of the configuration with the local storage, which is very cumbersome. If we don't pay enough attention, this could lead to some desynchronization.
With our userLocalStorage
hook, we'll be able to abstract some generic logic in a separate function to reduce the amount of code needed for such a simple feature. Also, we won't have to synchronize anything anymore, as this will become the hook's job.
Implementation
In the first place, let's discuss about the hook's signature (which means, what are its parameters and its return value). The local storage works by associating some string values to some keys.
// Get the value associated with the 'config' key
const rawConfig = localStorage.getItem('config');
// Parse the plain object corresponding to the string
const config = JSON.parse(rawConfig);
// Save the config
localStorage.setItem('config', JSON.stringify(config));
So our hook signature could look like this:
const [config, setConfig] = useLocalStorage('config');
The hook will set our config
variable to whatever value it finds in the local storage for the "config"
key. But what if it doesn't find anything? In that case, the config
variable would be set to null
. We would like to set a default value (in our example, set a default config) for this variable in case the local storage is empty for that key. To do so, we'll slightly change the hook's signature to accept a new optional argument: the default value.
const [config, setConfig] = useLocalStorage('config', defaultConfig);
We're now ready to start implementing the hook. First, we'll read the local storage value corresponding to our key
parameter. If it doesn't exist, we'll return the default value.
const useLocalStorage = (key, defaultValue = null) => {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
if (saved !== null) {
return JSON.parse(saved);
}
return defaultValue;
});
};
Great! We've made the first step of the implementation. Now, what happens if the JSON.parse
method throws an error? We didn't handle this case yet. Let's fix that by returning the default value once more.
const useLocalStorage = (key, defaultValue = null) => {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
if (saved !== null) {
return JSON.parse(saved);
}
return defaultValue;
} catch {
return defaultValue;
}
});
};
That's better! Now, what's next? Well, we just need to listen for the value
changes and update the local storage accordingly. We'll use the useEffect
hook to do so.
const useLocalStorage = (key, defaultValue = null) => {
const [value, setValue] = useState(...);
useEffect(() => {
const rawValue = JSON.stringify(value);
localStorage.setItem(key, rawValue);
}, [value]);
};
⚠️ Be aware that the
JSON.stringify
method can also throw errors. However, this time, it is not the job of this hook to handle those errors — except if you want to catch them in order to throw a custom one.
So, are we done? Not yet. First, we didn't return anything. Accordingly to the hook's signature, we just have to return the value and its setter.
const useLocalStorage = (key, defaultValue = null) => {
const [value, setValue] = useState(...);
useEffect(...);
return [value, setValue];
};
But we also to have to listen for the key
changes! Indeed, the value provided as an argument in our example was a constant ('config'
), but this might not always be the case: it could be a value resulting from a useState
call. Let's also fix that.
const useLocalStorage = (key, defaultValue = null) => {
const [value, setValue] = useState(...);
useEffect(() => {
const rawValue = JSON.stringify(value);
localStorage.setItem(key, rawValue);
}, [key, value]);
return [value, setValue];
};
Are we done now? Well, yes... and no. Why not? Because you can customize this hook the way you want! For instance, if you need to deal with the session storage instead, just change the localStorage
calls to sessionStorage
ones. We could also imagine other scenarios, like adding a clear
function to clear the local storage value associated to the given key. In short, the possibilities are endless, and I give you some enhancement ideas in a following section.
Usage
Back to our settings page example. We can now simplify the code that we had by using our brand new hook. Thanks to it, we don't have to synchronize anything anymore. Here's how the code will now look like:
const defaultConfig = {
theme: "light",
lang: "fr",
settings: {
pushNotifications: true
}
};
const Settings = () => {
const [config, setConfig] = useLocalStorage("config", defaultConfig);
const handleChange = (e) => {
// Still a bit tricky, but we don't really have any other choice
setConfig(oldConfig => ({
...oldConfig,
settings: {
...oldConfig.settings,
pushNotifications: e.target.checked
}
}));
};
return (
<>
<h1>Settings</h1>
<span class="p"><</span><span class="nt">label</span> <span class="na">htmlFor</span><span class="p">=</span><span class="s">"pushNotifications"</span><span class="p">></span>Push Notifications<span class="p"></</span><span class="nt">label</span><span class="p">></span>
<span class="p"><</span><span class="nt">input</span>
<span class="na">type</span><span class="p">=</span><span class="s">"checkbox"</span>
<span class="na">id</span><span class="p">=</span><span class="s">"pushNotifications"</span>
<span class="na">checked</span><span class="p">=</span><span class="si">{</span><span class="nx">config</span><span class="p">.</span><span class="nx">settings</span><span class="p">.</span><span class="nx">pushNotifications</span><span class="si">}</span>
<span class="na">onChange</span><span class="p">=</span><span class="si">{</span><span class="nx">handleChange</span><span class="si">}</span>
<span class="p">/></span>
<span class="p"></></span>
);
};
Improvement Ideas
- Handle exceptions of the
JSON.stringify
method if you need to - If the value becomes
null
, clear the local storage key (withlocalStorage.removeItem
) - If the key changes, remove the value associated with the old key to avoid using storage space unnecessarily
Conclusion
I hope this hook will be useful to you for your projects. If you have any questions, feel free to ask them in the comments section.
Thanks for reading me, and see you next time for a new custom hook. 🤗
Source code available on CodeSandbox.
Support Me
If you wish to support me, you can buy me a coffee with the following link (I will then probably turn that coffee into a new custom hook... ☕)
Top comments (5)
It would be nice to have an event listener like syntax to know if local storage has been updated.
Actually it is possible (see Window: storage event)
Is it what you're looking for? 🤔
I think that only applies if another session makes the change.
I was more thinking component-A makes a change to
settings.sound
and component-B has an event listener that means it can see the change without having to drill props.Ok I got you, and you're right, it would be useful. In that case, I think I'd go with the
useContext
hook.Some comments may only be visible to logged-in visitors. Sign in to view all comments.