Persisting Zustand with URLSearchParams and Zipson


We will be covering how to persist and compress client state in the URL using Zustand, URLSearchParams, and Zipson. First, we will look at URLSearchParams API and then, we will look at the libraries zustand for client-side state managament and zipson, a JSON compression library. Along the way we will build a simple example to demonstrate how to use these libraries together.

URLSearchParams

The URLSearchParams interface defines utility methods to work with the query string of a URL. It can be used to parse and manipulate the query string of a URL. The query string is the part of a URL that comes after the ? character.

  const urlSearchParams = new URLSearchParams(window.location.search);
  const searchParams = Object.fromEntries(urlSearchParams.entries());
  
  urlSearchParams.set('sort-by', 'date');
  urlSearchParams.set('sort-order', 'desc');
  urlSearchParams.set('some-feature', 'true');

  window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams}`);

  // Output: https://example.com/some-data-page?sort-by=date&sort-order=desc

As we can see in the above example, we can use the URLSearchParams API to parse and manipulate the query string of a URL. We can set, get, and delete query parameters using the set, get, and delete methods respectively. Although this API is quite powerful, it only supports string values and we need to manually serialize and deserialize complex data structures.

Zustand

Zustand is small and fast, flux pattern based state management library for React. It is a simple and straightforward library that provides a way to manage the state of your application. It is a great alternative to Redux and MobX as a Global state management solution. It is much more performant and easier to use than React Context API, provided you follow the best practices.

I prefer to use immer with zustand as it removes the dreaded spread.

The Dreaded Spread:

switch (action.type) {
  case "add-something":
    return {
      ...state,
      something: {
        ...state.something,
        items: [...state.something.items, action.payload],
      },
    };
  default:
    return state;
}

With immer:

switch (action.type) {
  case "add-something":
    return produce(state, (draft) =>
      draft.something.items.push(action.payload),
    );
  default:
    return state;
}

The Example

Suppose we have a Page in an application where we want to the user to be able to search and select multiple business units. With multiple business units selected, we want to be able to select a quarter or number of quarters, and a set of metrics to display.


import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

type Metric = 'revenue' | 'cost' | 'profit' | 'margin' | 'inventory';

type Quarter = `Q${1 | 2 | 3 | 4}Y${number}`;

// Excluding bulk operations for brevity
type Store = {
  state: {
    busUnits: string[];
    metrics: Metric[];
    quarters: Quarter[];
  },
  actions: {
    addBusUnit: (busUnit: string) => void;
    removeBusUnit: (busUnit: string) => void;

    addMetric: (metric: Metric) => void;
    removeMetric: (metric: Metric) => void;
    

    addQuarter: (quarter: Quarter) => void;
    removeQuarter: (quarter: Quarter) => void;

    reset: () => void;
  }
}

const defaultState: Store['state'] = {
  busUnits: [],
  metrics: [],
  quarters: [],
}


const useModalStore = create<Store>(
  immer((set) => ({
    state: defaultState,
    actions: {
      addBusUnit: (busUnit) =>
        set((state) => {
          state.busUnits.push(busUnit);
        }),
      removeBusUnit: (busUnit) =>
        set((state) => {
          state.busUnits = state.busUnits.filter((unit) => unit !== busUnit);
        }),
      addMetric: (metric) =>
        set((state) => {
          state.metrics.push(metric);
        }),
      removeMetric: (metric) =>
        set((state) => {
          state.metrics = state.metrics.filter((m) => m !== metric);
        }),
      addQuarter: (quarter) =>
        set((state) => {
          state.quarters.push(quarter);
        }),
      removeQuarter: (quarter) =>
        set((state) => {
          state.quarters = state.quarters.filter((q) => q !== quarter);
        }),
      reset: () => set(() => defaultState),
    },
  })),
);


// component.tsx

const Page = () => {
    return (
    <main 
      <SomeComponent />
      ...
    </main>
  );
}

const SomeComponent = () => {
  const [busUnits, addBusUnit, removeBusUnit] = useModalStore((state) => [state.busUnits, state.actions.addBusUnit, state.actions.removeBusUnit]);
  const [metrics, addMetric, removeMetric] = useModalStore((state) => [state.metrics, state.actions.addMetric, state.actions.removeMetric]);
  const [quarters, addQuarter, removeQuarter]= useModalStore((state) => [state.quarters, state.actions.addQuarter, state.actions.removeQuarter]);
  return <div>...</div>
}

This is great, we have a global store to access for our state, we can avoid prop drilling and we can easily add and remove items from our state. SomeComponent and its sibling components can access the state and actions from the store without the whole tree needing to be re-rendered.

For those who have a keen eye, you may have noticed that we seperated the state and actions into two seperate objects, which is not what the official zustand docs show in examples. Firstly, this is a pattern I like to use as its easier to read and understand the store. Secondly, it makes persistance with zustand easier, which we will cover next.

Adding some persistence

Now what if a user would like to keep their settings after reloading or reopening the page? We can use zustands first-party persist middleware to persist the state to local storage.


import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist, createJSONStorage } from 'zustand/middleware';

type Metric = 'revenue' | 'cost' | 'profit' | 'margin' | 'inventory';

type Quarter = `Q${1 | 2 | 3 | 4}Y${number}`;

// Excluding bulk operations for brevity
type Store = {
  state: {
    busUnits: string[];
    metrics: Metric[];
    quarters: Quarter[];
  },
  actions: {
    addBusUnit: (busUnit: string) => void;
    removeBusUnit: (busUnit: string) => void;

    addMetric: (metric: Metric) => void;
    removeMetric: (metric: Metric) => void;
    

    addQuarter: (quarter: Quarter) => void;
    removeQuarter: (quarter: Quarter) => void;

    reset: () => void;
  }
}

const defaultState: Store['state'] = {
  busUnits: [],
  metrics: [],
  quarters: [],
}


const useModalStore = create<Store>(
  persist(
    immer((set) => ({
      state: defaultState,
      actions: {
        addBusUnit: (busUnit) =>
          set((state) => {
            state.busUnits.push(busUnit);
          }),
        removeBusUnit: (busUnit) =>
          set((state) => {
            state.busUnits = state.busUnits.filter((unit) => unit !== busUnit);
          }),
        addMetric: (metric) =>
          set((state) => {
            state.metrics.push(metric);
          }),
        removeMetric: (metric) =>
          set((state) => {
            state.metrics = state.metrics.filter((m) => m !== metric);
          }),
        addQuarter: (quarter) =>
          set((state) => {
            state.quarters.push(quarter);
          }),
        removeQuarter: (quarter) =>
          set((state) => {
            state.quarters = state.quarters.filter((q) => q !== quarter);
          }),
        reset: () => set((store) => (store.state = defaultState)),
      },
    })),
    {
      name: "my-store", // the key the state will be stored under in local storage
      storage: createStorage(() => window.localStorage),
    },
  ),
);

Awesome, now our state will be persisted to local storage, but there are two problems:

  1. When Zustand stores and rehyrdates from localstorage it doesn’t automagically restore our actions, the hydration will actually leave them undefined.
  2. What if a user wants to share their settings with a colleague or save them to a bookmark to be reused in a different browser?

We will solve the first problem by using the partialize option in the persist middleware.

...
  {
    name: 'my-store', // the key the state will be stored under in local storage
    storage: createStorage(() => window.localStorage),
    partialize: (store) => {
      return {
        state: store.state,
      }
    }
  }
...

Now our state will be partially rehydrated from local storage and our actions will remain usable, not become undefined.

URLSearchParams and Zustand

In order to solve problem 2, we can use the URLSearchParams API to store our state in the URL. We will have to provide the our own storage interface to the persist middleware.

It will look something like this:

import { StateStorage } from 'zustand/middleware';

const urlSearchParamsStorage: StateStorage = {
  getItem: (name) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    return urlSearchParams.get(name);
  },
  setItem: (name, value) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    urlSearchParams.set(name, value);
    window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams}`);
  },
  removeItem: (name) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    urlSearchParams.delete(name);
    window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams}`);
  }
}

and then we can use it in our store like this:

...
  {
    name: 'my-store', // the key the state will be stored under in the URL 
    storage: createStorage(() => urlSearchParamsStorage),
    partialize: (store) => {
      return {
        state: store.state,
      }
    }
  }

Now our state will be stored in the URL and can be shared with others or saved as a bookmark.

But what if we have a lot of state or the state is very complex and clutters the URL?

Zipson

Enter zipson, a JSON compression library. We will use zipson to compress our state and base64 encode it before storing it in the URL.

import { StateStorage } from "zustand/middleware";
import { stringify, parse } from "zipson";

const urlSearchParamsStorage: StateStorage = {
  getItem: (name) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    const encoded = urlSearchParams.get(name);
    const compressed = B64.decode(encoded);
    return parse(compressed);
  },
  setItem: (name, value) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    const parsed = JSON.parse(value);
    const compressed = stringify(parsed);
    const encoded = B64.encode(compressed);
    urlSearchParams.set(name, encoded);
    window.history.replaceState(
      {},
      "",
      `${window.location.pathname}?${urlSearchParams}`,
    );
  },
  removeItem: (name) => {
    const urlSearchParams = new URLSearchParams(window.location.search);
    urlSearchParams.delete(name);
    window.history.replaceState(
      {},
      "",
      `${window.location.pathname}?${urlSearchParams}`,
    );
  },
};

// https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization/#safe-binary-encodingdecoding
class B64 {
  static decode(str: string): string {
    return decodeURIComponent(
      Array.prototype.map
        .call(window.atob(str), function (c) {
          return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
        })
        .join(""),
    );
  }

  static encode(str: string): string {
    return window.btoa(
      encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
        return String.fromCharCode(parseInt(p1, 16));
      }),
    );
  }
}


Now our state will be compressed and stored in the URL. We can share our settings with others or save them as a bookmark.

Drawbacks

There are definitly some drawbacks here, the URL is not readable and can be quite long, only javascript applications can read the URL, the soluion is not portable to other state management libraries because zustand stores the state object as, {state: { state: {...} }, version: number}. While it can be useful in some cases, e.g. map-based applications, this is usually not the solution you should be looking towards for most applications. You can avoid this complexity by improving your application architecture, having a better route structure, or perhaps improving your underlying data model.

Libs

Checkout the zipson and zustand repositories.