Chris Eckert Senior Software Engineer

From localStorage to cookies: persisting state across sessions

Throwback

A few years back I wrote a post about theme switchers. I was consistently seeing FOUC (Flash of Unstyled Content) around the web, and thought I’d creating simple approach to avoid it.

Though the approach worked, it only worked for CSR applications by persisting state in local storage, SSR environments still had issues.

In this post, I’ll revisit the setup, remove redundant state management, and handle SSR environments.

Original localStorage preview

Toggle dark, then reload to reproduce the old SSR flash where the page starts light and flips after hydration.

This pattern is caused by localStorage only being available on the client, so even though we try to sync the client state, we will fall back to the original light mode on render in SSR environments.

CSR Approach

First off, the original client-side approach had a lot of redundant state management, as I would sync localStorage to an in-memory state.

Creating a localStorage utility with useSyncExternalStore. We can now subscribe directly to the local storage data and receive updates whenever it updates, making it reactive. Also, it creates a common shared wrapper for getting localStorage items and setting storage items.

export const localStorage = {
  has: (key: string) => {
    return Boolean(localStorage.getItem(key));
  },
  getItem: (key: string) => {
    return window.localStorage.getItem(key);
  },
  setItem: (key: string, value: string) => {
    window.localStorage.setItem(key, value);
    // This event is normally dispatched when ANOTHER tab makes a modification to localStorage.
    // This normalizes the event into being dispatched in the current tab as well.
    window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
  },
  removeItem: (key: string) => {
    window.localStorage.removeItem(key);
    // This event is normally dispatched when ANOTHER tab makes a modification to localStorage.
    // This normalizes the event into being dispatched in the current tab as well.
    window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
  },
  subscribe: (callback: () => void) => {
    window.addEventListener('storage', callback);
    return () => window.removeEventListener('storage', callback);
  },
};

Using the localStorage utility in a component:

function ThemeManager() {
  const theme = useSyncExternalStore(
    localStorage.subscribe,
    () => localStorage.getItem(LOCAL_STORAGE_KEY) ?? 'light'
  );

  const updateTheme = () => {
    const nextTheme = theme === 'dark' ? 'light' : 'dark';
    localStorage.setItem(LOCAL_STORAGE_KEY, nextTheme);
  };

  return <ThemeSwitcher theme={theme} onToggle={updateTheme} />;
}

*If used in multiple components we would move this to a custom hook.

This works great for CSR applications, as you can see in the preview below.

localStorage preview

This version uses localStorage as the store itself, so mounted client instances stay in sync without mirrored component state.

preview://data-persistence

localStorage

localStorage is the source of truth, so mounted client instances stay in sync without extra state.

Source of truth: localStorage

Tailwind preview

The same text can feel calm in light mode and focused in dark mode.

This browser mock toggles a local dark class so Tailwind's dark: utilities can restyle the preview instantly.

SSR Approach

This still leaves us with a problem on SSR pages. localStorage only exists in the browser, so the server has no way to know which theme to render on the first request.

For SSR, we need a source of truth that both the server and the client can read. A cookie works well here because:

That means we are no longer restoring the theme after hydration. Instead, we render the correct theme up front and then hydrate with the same initial value.

import { cookies } from 'fake-server-utils';

const initialTheme = cookies.getCookie(COOKIE_KEY).value ?? 'light';

return <ThemeSwitcher theme={initialTheme} />;

This is the key difference from the CSR approach: the client is no longer responsible for discovering the initial theme. It receives that value from the server, which avoids the light-to-dark flip on reload. We still keep some in-memory state so the UI can respond immediately to interactions, but the cookie becomes the persistence layer shared across requests.

function ThemeManager(props: { initialTheme: string }) {
  const [theme, setTheme] = useState(props.initialTheme);

  const updateTheme = () => {
    const nextTheme: Theme = theme === 'dark' ? 'light' : 'dark';

    // sync the in-memory state and the cookie
    setTheme(nextTheme);
    cookies.setCookie(COOKIE_KEY, nextTheme);
  };

  return <ThemeSwitcher theme={theme} onToggle={updateTheme} />;
}

I created a small client utility for cookies to get and set cookies. In the future we can use the CookieStore API to subscribe to changes through useSyncExternalStore.

export const cookies = {
  isAvailable: () => {
    if (typeof window === 'undefined') {
      return false;
    }

    return true;
  },
  getCookie: (key: string) => {
    if (!cookies.isAvailable()) return;

    return document.cookie
      .split('; ')
      .find((row) => row.startsWith(`${key}=`))
      ?.split('=')[1];
  },
  setCookie: (key: string, value: string) => {
    document.cookie = `${key}=${value}; path=/`;
  },
  removeCookie: (key: string) => {
    document.cookie = `${key}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
  },
};

Cookie SSR preview

Toggle the theme, then reload to keep the saved value from the first paint.

Conclusion

There are many things to keep in mind, even when building such a simple component:

Below, you can see the three common approaches: state, localStorage, and cookies. Each has its own strengths and weaknesses depending on the UX that you want.

Comparison preview

Switch between all three persistence strategies and reload each one to compare the next paint.

Notes

We could also go into handling system preferences and improve type safety for the values stored in localStorage and cookies, and allowing for non-string values/complex data structures.

Gist for the full localStorage utility. https://gist.github.com/chrrrs/325b60df7777bb5991d71ed956c25513

Gist for the full cookies utility. https://gist.github.com/chrrrs/e9328b9f49cf62f00696cafe2f5b353c

[Original Post] How to Create a Theme Switcher in React (2020)

Theme switchers have become more and more of a standard during the last few years. Mostly used as a night mode toggle. There are a wide variety of approaches for creating your desired effect, but some seem overly complex to get up and running for such a small task. With React we get some tools that makes the addition of a theme switcher very easy to implement and also manage with the componentization of our code.

This project can easily be followed with the basic create-react-app setup.

The final code can be found here - https://codesandbox.io/s/blog-theme-switcher-j0o2g

Lets start of by building a very simple switch component. I created a components folder in my project directory and nested a folder for our ThemeSwitcher component.

At first we create our component. This is just a very basic functional component. I downloaded two SVG icons from FontAwesome. We will later switch between these based on our theme state.

import React from 'react';
import { ReactComponent as Sun } from '../../icons/sun.svg';
import { ReactComponent as Moon } from '../../icons/moon.svg';

const ThemeSwitcher = () => {
  return (
    <div className="switcher">
      <Sun />
      <Moon />
    </div>
  );
};

export default ThemeSwitcher;

We add a little bit of CSS to handle the sizing and that’s it.

.switcher {
  width: 50px;
  cursor: pointer;
}

@media (min-width: 420px) {
  .switcher {
    width: 1.5rem;
  }
}

And lastly create a index.js file which main focus is to easily let us export our component. It also lets us import our CSS in a efficient way.

import './ThemeSwitcher.css';

export { default } from './ThemeSwitcher';

You can now import the Switcher component to the page where you want to display it or keep it in a wrapper if it needs to be shown on every page.

I created a few other elements, which we will use to show how the theme changes.

Right now our switcher doesn’t do much and for one thing it doesn’t even switch between the icons - lets fix that.

Adding a global state

For having an updatable state we will use the React context api. With context we are able to create global state which is available everywhere in our app.

This of course entails that we use functional components. We could use class based components and a normal state, but that would mean we would have to prop our values, functions etc. down the component tree. This could lead to harder debugging and lessen our flexibility with the components.

Setting up the context api is dreaded by some, you have to keep track of providers and consumers. It can become a bit messy. Below I would like to introduce an approach for setting up a context without a big fuss.

Lets create a context folder in the project source folder and add our themeContext.js file. Here we first setup our base context without any default context state. Which of course would return us undefined when using the context.

import React, { createContext } from 'react';
import PropTypes from 'prop-types';

export const ThemeContext = createContext();

We could of course define our values inside the create context but we would lose a lot of functionality. Instead of using our context directly in our files and setting our state in that initial component, like a layout component or the like. But it is way nicer having all of our context logic in one place. So we create a ThemeContextProvider component below, in here we can setup states, global functions, variables etc.

So we setup our initial theme state and provide it to the context, thereby making it available to the children through useContext.

import React, { createContext } from 'react';
import PropTypes from 'prop-types';

// initialize our theme context
export const ThemeContext = createContext();

//setup a provider component, wrapped views, components etc.
// can access the context through here
export const ThemeContextProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');

  return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};

ThemeContextProvider.propTypes = {
  children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};

We export both the ThemeContext and the provider component from the file to access theme throughout the app.

Now we will wrap our app component with our provider in the index.js file. If you have multiple routes or the like, you should make sure to wrap them, so that the state is available in all the routes.

// ...
import { ThemeContextProvider } from './context/themeContext';

const rootElement = document.getElementById('root');
ReactDOM.render(
  <React.StrictMode>
    {/* wrap the app component */}
    <ThemeContextProvider>
      <App />
    </ThemeContextProvider>
  </React.StrictMode>,
  rootElement
);

We are now able to use our context in all our child components. So head over to our switcher component to destructure our theme value and update function.

Setting up the logic

We’ll add it to our wrapper div but we need to add some logic, so we’ll configure an update function. This one will check whether or not the theme state is equal to “light”, based on this we will either set the theme to “dark” or “light”. We’ll also show either the sun icon or the moon icon based on our state

import React, { useContext } from 'react';
// ...
import { ThemeContext } from '../../context/themeContext';

const ThemeSwitcher = () => {
  const { theme, setTheme } = useContext(ThemeContext);
  const isLight = theme === 'light';

  const updateTheme = () => (isLight ? setTheme('dark') : setTheme('light'));

  return (
    <div className="switcher" onClick={() => updateTheme()}>
      {!isLight && <Sun />}
      {isLight && <Moon />}
    </div>
  );
};

export default ThemeSwitcher;

You can now switch between the two icons by updating our global context state. To control the other elements of the page we will add our theme value to our outermost parent component. In our case the App.js file.

Adding styling to your app

You can either add the theme as a class property as we have provided it as string. We could also set it as a data-attribute, but I will go with the more traditional approach for now.

// ...
import { ThemeContext } from './context/themeContext';

export default function App() {
  const { theme } = useContext(ThemeContext);

  return <div className={`App ${theme}`}>{/* ... */}</div>;
}

The next step is to setup our CSS and change styling when the .dark class is added to our base component.

.App {
  /* ...  */
  background: #fff;
  color: #202020;
  transition: all 0.2s ease-in-out;
}

.App.dark {
  background: #202020;
  color: #fff;
}

.button {
  /* ...  */
  background: #f2f2f2;
  color: #5f5f5f;
  transition: all 0.2s ease-in-out;
}

.dark .button {
  color: #f2f2f2;
  background: #6c737e;
}

.button:hover {
  filter: invert(10%);
}

.dark .button:hover {
  filter: invert(65%);
}

You should now be able to easily switch your theme based on the added class.

Great job but we can definitely improve our approach for changing the colors.

Adding a .dark property to all possible classes that need to change can become quit cumbersome, especially in larger applications. What if colors need to change suddenly, you would have to go through your whole app and adjust your colors one by one.

Optimizing with CSS Variables

Luckily CSS has given us variables, a very powerful way to have global CSS variables that can change on runtime, something that is not supported in the very popular Sass library.

First of lets define our variables at the top of the page. Ideally, in larger projects, you would ensure that these would be defined in your outermost CSS file to ensure that they are available throughout your whole application. Remember the file with your variables should be loaded in on every page.

:root {
  --primary: #202020;
  --secondary: #f2f2f2;
  --background: #ffffff;
}

Now can change the variables when the .dark class has been added. Doing this will overwrite our variables when the .dark class is added to our parent element. This saves us from writing a lot of the same styling with minor changes.

.dark {
  --primary: #f2f2f2;
  --secondary: #2b2b2b;
  --background: #202020;
}

The last thing we have to do is switch out our hardcoded colors with our variables. We do that by adding var() and add the variable name within the parentheses.

.App {
  /* ...  */
  background: var(--background);
  color: var(--primary);
  /* ... */
}

.button {
  /* ...  */
  background: var(--secondary);
  color: var(--primary);
  /* ... */
}

Now we can quickly change our variables to anything we want and the app will respond.

Blog Theme Switcher

Now if you are coding along you might notice that, with the approach above, we need to re-set our preferred theme each time we visit the page. If your page is a one-time drop-in that might be a-okay, but if you have regular users, they might grow tired of switching to their desired theme each time they visit your site.

Configuring for persisting theme through local-storage

So we need to somehow persist a users theme preference. Well you could setup user login and have it all stored in a database, but again that’s a huge feat for just storing whether or not you like night mode. Instead we can take advantage of the users browser, where we have access to local-storage.

First up we can add a storeTheme function that handles both setting the theme to our global state as well as storing in the local storage. This will save us from writing the same logic twice, as we are doing the same thing on each side of the ternary operator.

Now instead of setting the global variable directly in the updateTheme function we can just pass in the string we want to store in both our state and local-storage.

Lastly we need to add a useEffect for checking if there is a theme value stored in our local-storage. If that is the case then we want to update our theme context with the stored value. We set an empty array as our dependency for the useEffect, to say, we only want this to run on its initial mount.

And that’s it!

// 3
useEffect(() => {
  const persistantTheme = window.localStorage.getItem('theme');

  if (persistantTheme) {
    setTheme(persistantTheme);
  }
}, []);

// 1
const storeTheme = (themeValue) => {
  setTheme(themeValue);
  window.localStorage.setItem('theme', themeValue);
};

//2
const updateTheme = () => (isLight ? storeTheme('dark') : storeTheme('light'));

You will see small flash on initial load as the color switches, something that could be fixed in different ways for example with SSR but that’s for another post.

If you are looking for some additional or supplementary reading on the topics covered in this tutorial, please take a look at the following links.