Cat logomenu

How to Persist React Form State to URL Search Params

A very nice thing you can do for your users is make it possible for them to share or restore the specific state of a form by copying and pasting a URL. This is the simplest and friendliest version of a feature you see all the time in applications like hotel-booking sites or flight search engines: “Share this Search”. If you think your users might ever need to send someone else a link to say “Check out the results of this search I did”, the URL Search Params are your friend. Even if you just want form state to persist between reloads this probably the best way.

I recently needed to implement this feature for two forms in the application I work on in my day job, so I encapsulated it into a React hook I could then call from any form. Here’s the full code for it up front, in case you just want to grab and get back to your own work:

import { useEffect } from "react";

function getSearchParamsChangedByUser(
  changed: Record<string, string | string[] | undefined>,
  [key, value]: [key: string, value: any],
  searchParams: URLSearchParams
) {
  if (Array.isArray(value)) {
    if (JSON.stringify(value) === JSON.stringify(searchParams.getAll(key))) {
      return changed;
    } else {
      return {
        ...changed,
        [key]: value,
      };
    }
  } else {
    if (String(value) === (searchParams.get(key) ?? "")) {
      return changed;
    } else {
      return {
        ...changed,
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        [key]: value,
      };
    }
  }
}

function deleteSearchParamAndSetNew(
  params: URLSearchParams,
  key: string,
  value: string | string[] | undefined
) {
  params.delete(key);
  const isEmpty =
    !value || (Array.isArray(value) && value.length === 0) || value === "0";
  if (!isEmpty) {
    if (Array.isArray(value)) {
      value.forEach((selection) => {
        params.append(key, selection);
      });
    } else {
      params.set(key, String(value));
    }
  }
}

export function updateUrlQueryParams(
  updatedParams: Record<string, string | string[] | undefined>,
  existingParams: URLSearchParams
) {
  const params = new URLSearchParams(existingParams);
  Object.entries(updatedParams).forEach(([key, value]) => {
    deleteSearchParamAndSetNew(params, key, value);
  });
  return params;
}

export function usePersistFormStateToUrlQueryParams<
  TFieldValues extends Record<string, any> = Record<string, any>
>(currentFormValues: TFieldValues) {
  useEffect(() => {
    const queryString = window.location.search;
    const searchParams = new URLSearchParams(queryString);
    const changedKeys = Object.entries(currentFormValues).reduce(
      (changed, [key, value]) =>
        getSearchParamsChangedByUser(changed, [key, value], searchParams),
      {}
    );
    const updatedParams = updateUrlQueryParams(changedKeys, searchParams);

    if (updatedParams.toString() !== searchParams.toString()) {
      const pathname = window.location.pathname;
      window.history.replaceState(
        {},
        "",
        `${pathname}?${updatedParams.toString()}`
      );
    }
  }, [currentFormValues]);
}

In your form component, you would then retrieve the URL Search Params on mount and use them as the initial values for your form so that the user’s state is restored.


Still here? Great: Let’s go over this in detail. We’ll start at the bottom, with the main hook, usePersistFormStateToUrlQueryParams:

export function usePersistFormStateToUrlQueryParams<
  TFieldValues extends Record<string, any> = Record<string, any>
>(currentFormValues: TFieldValues)

The only important thing to know here is that I based the type argument on the pattern of react-hook-form. If you’re doing that, the easiest way to use this in the actual form is like this:

const { getValues } = useForm(); // import { useForm } from "react-hook-form";
usePersistFormStateToUrlQueryParams({...getValues()});

But you can change the type argument to suit your own needs. (It would also be easy to strip TypeScript out of this entirely.)

useEffect(() => {
  { ... }
}, [currentFormValues]);

We wrap our logic in a useEffect that runs any time our currentFormValues argument changes—that is, when the form state has changed. This means that every time a form input value changes, the URL Search Params will update to reflect the change.

const queryString = window.location.search;
const searchParams = new URLSearchParams(queryString);
const changedKeys = Object.entries(currentFormValues).reduce(
  (changed, [key, value]) =>
    getSearchParamsChangedByUser(changed, [key, value], searchParams),
    {}
  );
const updatedParams = updateUrlQueryParams(changedKeys, searchParams);

Here we:

  1. Get the current query string and transform it into an object that implements URLSearchParams, which has a great API for us to work with
  2. Iterate over the currentFormValues to check whether they have changed since the last time we updated the URL Search Params
  3. Pass the resulting changedKeys object to a function that returns an updated URLSearchParams object
if (updatedParams.toString() !== searchParams.toString()) {
  const pathname = window.location.pathname;
  window.history.replaceState(
    {},
    "",
    `${pathname}?${updatedParams.toString()}`
  );
}

Next we check that the updatedParams are in fact different than the existing searchParams. This is necessary because our getSearchParamsChangedByUser function might return an empty object, which would result in updatedParams that have all the same keys and values as searchParams1. Since updating the URL Search Params often has the effect of re-rendering the React component that calls usePersistFormStateToUrlQueryParams, this might easily get you into an infinite loop.

Finally, we call window.history.replaceState with our new URL. We use replaceState instead of pushState to avoid proliferating history entries.

So that’s our main hook function. What about the functions we call to get our changed form values and return updated query params?

function getSearchParamsChangedByUser(
  changed: Record<string, string | string[] | undefined>,
  [key, value]: [key: string, value: any],
  searchParams: URLSearchParams
)

The arguments here are dictated by the requirements for an Array.prototype.reduce callback function when it’s called on Object.prototype.entries(). changed is the accumulator, and the [key, value] pair is from the object iterator. We use Record<string, string | string[] | undefined> as the type for the accumulator because new URLSearchParams() will only accept string or string [] as the value for one of its entries. (We’ll handle undefined values later, before we create the new URLSearchParams object.)

if (Array.isArray(value)) {
  if (JSON.stringify(value) === JSON.stringify(searchParams.getAll(key))) {
    return changed;
  } else {
    return {
      ...changed,
      [key]: value,
    };
  }
}

Here we have our logic for handling a form value that takes the shape of an array of strings. URL Search Params store arrays as repeated entries with the same key. If you feed a new URLSearchParams() something like { animals: ["unicorn", "manticore", "dragon"] }, you’ll end up with ?animals=unicorn&animals=manticore&animals=dragon as your Search Params. Therefore we retrieve the current value using the .getAll() method instead of .get() (which would return only the first item in the array, e.g., “unicorn”) and compare the two arrays using JSON.stringify(). (You could also use Array.prototype.toString, but JSON.stringify() is what always leaps to my mind.)

else {
  if (String(value) === (searchParams.get(key) ?? "")) {
    return changed;
  } else {
    return {
      ...changed,
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      [key]: value,
    };
  }
}

Now we handle any other type of form value. Here… I’ll be honest. I didn’t test this against form values of type number because I generally avoid implementing them. In fact, there’s a whole range of more complicated form state that would probably make this fall flat on its face. Adapt as necessary.

You’ll notice that we now can use URLSearchParams.get() instead of getAll(), since we don’t need to get a full list of array-type values. .get() might return undefined, but we want to make sure we are comparing strings to strings, so we use the nullish coalescing operator ?? to fall back to an empty string if searchParams.get(key) returns undefined.

Also worth noting is that if your TypeScript config is less particular than mine, you might not need the eslint-disable-next-line.

In both this block and the one that handles array values, what we essentially do is check whether the new value is the same as the old value. If so we just return the accumulator back to our reducer. Otherwise, we spread the accumulator and just alter the one value when we return.

Okay, so we’ve checked which of our form values have changed; now we need to get an updated URLSearchParams object to put back into the browser history.

export function updateUrlQueryParams(
  updatedParams: Record<string, string | string[] | undefined>,
  existingParams: URLSearchParams
) {
  const params = new URLSearchParams(existingParams);
  Object.entries(updatedParams).forEach(([key, value]) => {
    deleteSearchParamAndSetNew(params, key, value);
  });
  return params;
}

This is basically just a wrapper around another Object.prototype.entries method. We pass in our Record that has the updated object we’re going to put into the new Search Params, along with the current params. Then we call deleteSearchParamAndSetNew on each entry in our updatedParams object.2

function deleteSearchParamAndSetNew(
  params: URLSearchParams,
  key: string,
  value: string | string[] | undefined
) {
  params.delete(key);
  const isEmpty =
    !value || (Array.isArray(value) && value.length === 0) || value === "0";
  if (!isEmpty) {
    if (Array.isArray(value)) {
      value.forEach((selection) => {
        params.append(key, selection);
      });
    } else {
      params.set(key, String(value));
    }
  }
}

First we delete the current key from the current URLSearchParams. It will be reset if the updated params object contains a value for that key. This is to avoid having ugly things like ?animals= in our Search Params—keys with no corresponding value.

Then we check whether there is, in fact, a value for that key. You’ll want to look closely at the isEmpty declaration to make sure it aligns with what you would consider to be “empty” values. For example, you might want to consider “0” to be a non-empty value.

If the value is “empty” we don’t reset the property on the Search Params. But if it’s not empty, we again have to choose between the method we’re going to use to reset it. Array values need to use .append(), whereas string values can use get, just as when retrieving values we use getAll() for array values and get() for strings.

And that’s all! Once updateUrlQueryParams has passed the entire updated values object through deleteSearchParamAndSetNew, it returns a new URLSearchParams object to our useEffect, which updates the history with the new Search Params.

Hopefully this was useful. If you catch a bug or think I could have explained something better, please drop me a line or hit me up on Mastodon. Happy form-building!


  1. I know I said before that the useEffect runs every time our form state has changed, but that was in the nature of a Terry Pratchett-style “helpful lie”. Technically it runs every time the currentFormValues argument we pass to the hook has changed. Due to the way JavaScript and React work, this will include any time the component re-renders, even if none of the form input values have changed. If you want to understand more about JavaScript object equality, you can read “Object Equality in JavaScript” by Joshua Clanton. Or, to really level up, you can do my preferred thing and read the entire “You Don’t Know JS” series by Kyle Simpson.
  2. Generally I’m a functional programming person, so it a little bit galls me to mutate this URLSearchParams object like this. But the API for URLSearchParams is so nice, and doing this the functional way is so verbose and comparatively unclear, that sacrificing my principals is the more pragmatic course. Believe me, I tried it the other way.

Give us a share!