DEV Community

Cover image for Custom React Hooks: useLocalStorage
Ludal šŸš€
Ludal šŸš€

Posted on

Custom React Hooks: useLocalStorage

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Settings page preview

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}
            />
        </>
    );
};
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

So our hook signature could look like this:

const [config, setConfig] = useLocalStorage('config');
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
    });
};
Enter fullscreen mode Exit fullscreen mode

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;
        }
    });
};
Enter fullscreen mode Exit fullscreen mode

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]);
};
Enter fullscreen mode Exit fullscreen mode

āš ļø 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];
};
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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>

      <label htmlFor="pushNotifications">Push Notifications</label>
      <input
        type="checkbox"
        id="pushNotifications"
        checked={config.settings.pushNotifications}
        onChange={handleChange}
      />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Improvement Ideas

  • Handle exceptions of the JSON.stringify method if you need to
  • If the value becomes null, clear the local storage key (with localStorage.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... ā˜•)

Buy Me A Coffee

Discussion (4)

Collapse
link2twenty profile image
Andrew Bone

It would be nice to have an event listener like syntax to know if local storage has been updated.

Collapse
iamludal profile image
Ludal šŸš€ Author

Actually it is possible (see Window: storage event)
Is it what you're looking for? šŸ¤”

Collapse
link2twenty profile image
Andrew Bone

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.

Thread Thread
iamludal profile image
Ludal šŸš€ Author • Edited on

Ok I got you, and you're right, it would be useful. In that case, I think I'd go with the useContext hook.