daniel reed

Migrating to nuqs

URL state management is one of those things that seems so simple to just read and write query parameters. In a recent project, I found myself wrestling with React Router's useSearchParams hook more and more. State synchronization issues, type safety concerns, and a lack luster developer experience were becoming regular obstacles.

This is the story of how I migrated from React Router's useSearchParams to nuqs, a type-safe search params state manager for React, and what I learned along the way.

The Problem with useSearchParams

Before diving into the solution, let's understand what we were dealing with. React Router's useSearchParams hook follows a pattern similar to useState:

const [searchParams, setSearchParams] = useSearchParams();
const slotId = searchParams.get('slotId');
setSearchParams({ slotId: '123' });

While this works, I encountered several pain points:

1. No Type Safety

Every value retrieved from searchParams.get() returns string | null. This meant constant manual parsing and type checking:

const pageIdString = searchParams.get('id');
const pageId = pageIdString ? parseInt(pageIdString, 10) : null;
if (isNaN(pageId)) {
  // Handle invalid input
}

2. Asynchronous Updates Without Promises

setSearchParams updates are asynchronous but don't return a promise. This made it impossible to await URL changes, leading to race conditions:

// This doesn't work as expected
setSearchParams({ pageId: '123' });
// The URL might not be updated yet!
clickOnElement(); // Might fail because URL isn't ready

Enter nuqs

nuqs is a library that treats URL search parameters as React state, with some major improvements:

Create Custom Hooks

I created dedicated hooks for each query parameter we used throughout the app. This gave me a centralized place to define types and parsing logic:

// hooks/usePageId.ts
import { parseAsInteger, useQueryState } from "nuqs";

export const usePageIdFromUrl = () => {
  return useQueryState("pageId", parseAsInteger);
};

This way, I abstract away all of the custom URL logic into a reusable hook. Yes, you could do the same with useSearchParams but now you get the added bonus of the type safety.

Replace useSearchParams Calls

The actual migration was straightforward. Here's a before/after comparison:

Before:

import { useSearchParams } from 'react-router-dom';

const Component = () => {
  const [searchParams, setSearchParams] = useSearchParams();
  const pageIdString = searchParams.get('pageId');
  const pageId = pageIdString ? parseInt(pageIdString, 10) : null;

  const openDialog = (id: number) => {
    setSearchParams({ pageId: id.toString() });
  };

  return <button onClick={() => openDialog(123)}>Open</button>;
};

After:

import { usePageIdFromUrl } from 'hooks/usePageIdFromUrl';

const Component = () => {
  const [pageId, setPageId] = usePageIdFromUrl();

  const openDialog = async (id: number) => {
    await setPageId(id);
  };

  return <button onClick={() => openDialog(123)}>Open</button>;
};

Notice:

Handle Async Updates

One of the biggest improvements was properly handling async updates. Previously, I was seeing race conditions where I'd update the URL and immediately perform an action that depended on the URL being updated.

The Problem (Before):

const doSomething = () => {
  // ...do some business logic
  // Clear slotId URL param when closing dialog
  void setPageId(null); // ⚠️ Not awaited!
  // Other code that might depend on URL being cleared...
};

The Solution (After):

const doSomething = async () => {
  // ...do some business logic
  // Clear slotId URL param when closing dialog
  await setPageId(null); // ✅ Properly awaited!
  // URL is guaranteed to be updated now
};

Add ESLint Rules

To prevent future regressions, I added ESLint rules to catch any usage of useSearchParams:

// eslint.config.mjs
{
  "no-restricted-syntax": [
    "error",
    {
      selector: "ImportDeclaration[source.value='react-router-dom'] ImportSpecifier[imported.name='useSearchParams']",
      message: "Avoid using `useSearchParams` from react-router-dom. Consider using `useQueryState` from nuqs."
    },
    {
      selector: "CallExpression[callee.name='useSearchParams']",
      message: "Avoid calling `useSearchParams` from react-router-dom. Use `useQueryState` from nuqs instead."
    }
  ]
}

Now if anyone tries to use useSearchParams, they'll get a helpful error message pointing them to nuqs, as well as failing linting to catch the error before it gets merged.

Pros and Cons

Pros ✅

  1. Type Safety Out of the Box

    • Built-in parsers: parseAsInteger, parseAsString, parseAsBoolean, parseAsStringLiteral, etc.
    • Custom parsers are easy to create
    • TypeScript knows the exact type without manual casting
  2. Promise-Based Updates

    • Can await URL changes
    • Eliminates race conditions
    • Better control flow in async operations
  3. Automatic Serialization

    • No more manual toString() and parseInt()
    • Handles arrays, objects, and custom types
    • Consistent serialization logic
  4. Better Developer Experience

    • Simpler API surface
    • Less boilerplate
    • Easier to test

Cons ❌

  1. Additional Dependency

    • Adds ~3.5kb to bundle (minimal, but worth noting)
    • Another library to maintain and update
  2. Learning Curve

    • Team needs to learn new API
    • Different mental model from useSearchParams
    • Need to understand parsers and serializers
  3. Migration Effort

    • Required touching many files
    • Need to update tests
    • Risk of introducing bugs during migration

Would I Do It Again?

Absolutely yes. Despite the migration effort, the benefits far outweigh the costs:

Conclusion

Migrating from useSearchParams to nuqs was one of those refactors that you don't realize you need until you do it. What started as "let's add some type safety" turned into "wow, I've been fighting the wrong abstractions this whole time."

The key takeaways:

  1. Type safety matters - Even for simple query parameters
  2. Async primitives are good - Promises are better than void operations
  3. Good abstractions scale - nuqs grows with your needs
  4. Migration effort pays off - Better DX = fewer bugs = happier developers

If you're building a React application with non-trivial URL state management, I highly recommend giving nuqs a try. Start with one or two query parameters, create wrapper hooks, and see how it feels.

#eslint #react #typescript