Logo
Cover image

Custom React Hooks: useLocalStorage

Posted on 11/7/2021

reactjavascriptprogramming

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 deal with the local storage more easily: 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, or settings). To save the config, you'll use an object that could look like this:

1const config = {
2  theme: 'dark',
3  lang: 'fr',
4  settings: {
5    pushNotifications: true
6  }
7}

In the settings page, you would let the user customize its configuration, in which case you will need to synchronize the UI state with the local storage. For instance, the settings page could look like this:

Settings page preview

And the corresponding source code could be similar to this:

1const defaultConfig = {
2  theme: 'dark',
3  lang: 'fr',
4  settings: {
5    pushNotifications: true
6  }
7};
8
9const Settings = () => {
10  const [config, setConfig] = useState(() => {
11    const saved = localStorage.getItem('config');
12    if (saved !== null) {
13      return JSON.parse(saved);
14    }
15    return defaultConfig;
16  });
17
18  const handleChange = (e) => {
19    setConfig(oldConfig => {
20      const newConfig = {
21        ...oldConfig,
22        settings: {
23          ...oldConfig.settings,
24          pushNotifications: e.target.checked
25        }
26      };
27
28      localStorage.setItem('config', JSON.stringify(newConfig));
29      return newConfig;
30    })
31  }
32
33  return (
34    <>
35      <h1>Settings</h1>
36      <label htmlFor="pushNotifications">
37        Push Notifications
38      </label>
39      <input
40        type="checkbox"
41        id="pushNotifications"
42        checked={config.settings.pushNotifications}
43        onChange={handleChange}
44      />
45    </>
46  );
47};

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 useLocalStorage hook, we will 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: this will become the hook's responsibility.

Implementation

In the first place, let's discuss the hook's signature (what are its parameters and its return value). The local storage works by associating some string values to some keys.

1// Get the value associated with the 'config' key
2const rawConfig = localStorage.getItem('config');
3
4// Parse the plain object corresponding to the string
5const config = JSON.parse(rawConfig);
6
7// Save the config
8localStorage.setItem('config', JSON.stringify(config));

As a result, our hook signature could look like this:

1const [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 simply be set to null.

We could be able 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 the default value as an optional argument.

1const [config, setConfig] = useLocalStorage('config', defaultConfig);

We're now ready to start implementing the hook. 😎

First, we will read the local storage value corresponding to our key parameter. If it doesn't exist, we will return the default value.

1const useLocalStorage = (key, defaultValue = null) => {
2  const [value, setValue] = useState(() => {
3    const saved = localStorage.getItem(key);
4    if (saved !== null) {
5      return JSON.parse(saved);
6    }
7    return defaultValue;
8  });
9};

We have made the first step of the implementation. But 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.

1const useLocalStorage = (key, defaultValue = null) => {
2  const [value, setValue] = useState(() => {
3      const saved = localStorage.getItem(key);
4      if (saved !== null) {
5        try {
6          return JSON.parse(saved);
7        } catch {
8          return defaultValue;
9        }
10      }
11      return defaultValue;
12  });
13};

That's better. Now, what's next? Well, we just need to listen for the value changes and update the local storage accordingly. To do so, we will use the useEffect hook.

1const useLocalStorage = (key, defaultValue = null) => {
2  const [value, setValue] = useState(/* ... */);
3
4  useEffect(() => {
5    const rawValue = JSON.stringify(value);
6    localStorage.setItem(key, rawValue);
7  }, [value]);
8};

Be aware that the JSON.stringify method can also throw errors. What should you do then? Propagate it? This is up to you.

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.

1const useLocalStorage = (key, defaultValue = null) => {
2  const [value, setValue] = useState(/* ... */);
3
4  useEffect(/* ... */);
5
6  return [value, setValue];
7};

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.

1const useLocalStorage = (key, defaultValue = null) => {
2  const [value, setValue] = useState(/* ... */);
3  const [oldKey, setOldKey] = useState(key)
4
5  useEffect(() => {
6    const rawValue = JSON.stringify(value);
7    localStorage.setItem(key, rawValue);
8    localStorage.removeItem(oldKey);
9    setOldKey(key);
10  }, [key, value]);
11
12  return [value, setValue];
13};

As you can see, we also take care of removing the previous key (and value) from the local storage in order to not overload the storage.

With this implementation, we are now done. However, you can still customize this hook depending on your needs. 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 with 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. We don't have to synchronize anything anymore. Here's how the code will now look like:

1const defaultConfig = {
2  theme: "light",
3  lang: "fr",
4  settings: {
5    pushNotifications: true
6  }
7};
8
9const Settings = () => {
10  const [config, setConfig] = useLocalStorage("config", defaultConfig);
11
12  const handleChange = (e) => {
13    // Still a bit tricky, but we don't really have any other choice
14    setConfig(oldConfig => ({
15      ...oldConfig,
16      settings: {
17        ...oldConfig.settings,
18        pushNotifications: e.target.checked
19      }
20    }));
21  };
22
23  return (
24    <>
25      <h1>Settings</h1>
26
27      <label htmlFor="pushNotifications">Push Notifications</label>
28      <input
29        type="checkbox"
30        id="pushNotifications"
31        checked={config.settings.pushNotifications}
32        onChange={handleChange}
33      />
34    </>
35  );
36};

Improvement Ideas

  • Handle errors of the JSON.stringify method if you need to
  • If the value becomes null, clear the local storage key (with localStorage.removeItem)
  • Create a generic useStorage hook that takes the storage object to use as an argument.

Wrapping Up

Once more, we've drastically simplified our code by implementing a custom hook. However, our implementation is not limited to what we currently have: we could enhance it and customize it the way we want depending on our needs, which is why these hooks are so powerful. In the next episode, we will have a look at another useful one: useNetworkState.


Source code available on CodeSandbox.

Copyright © 2022 Ludovic CHOMBEAU