Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Confusion Around useRouter() for shallow routing

I'm quite confused about the behavior of useRouter() in Next.js, specifically with the push and replace methods.

My goal is to update the URL parameters as soon as my input changes.

To achieve this, I revisited the Next.js docs and found an example for setting new searchParams using the useRouter hook like this:

router.push('/?counter=10', undefined, { shallow: true })

The key part here is the shallow option, as I wanted to avoid a full page reload. However, I soon realized that this option has been removed in the Next.js App Router, without any clear replacement. So, I was back to square one without a solution.

Afterward, I came across a section in the Next.js docs that provided an example for updating searchParams like this:

router.push(pathname + '?' + createQueryString('sort', 'asc'))

However, after implementing this, I found several articles and sources saying it’s not recommended to use router for changing URL parameters, as it can trigger a refetch and cause a full reload of the page. You can see this mentioned in the Next.js docs and also in various articles.

Here’s where things got strange: Although the Next.js docs say router.push will cause refetching and reloading, I didn’t observe any of that in my logs. I used a useEffect without a dependency array to check if my Navlinks component would rerender when another component (with the input) was using router.push, but it didn't:

useEffect(() => {
  console.log("rerender Navlinks");
});

It seemed to work, but the updates to the URL bar were laggy and slow when updating the search parameters. So, even though I couldn't confirm a full reload, there was definitely something odd happening, which I couldn't pinpoint.

As an alternative, I tried:

window.history.replaceState(null, "", pathname + '?' + createQueryString('sort', 'asc'));

Interestingly, this was much more responsive in the URL bar and didn’t feel as sluggish. It worked fine, but I don’t understand why this method performs better than router.replace. I couldn't figure out why router.replace was slower.

Of course, I could have simplified things by using Nuqs, but constantly adding new packages to solve problems doesn't help in the long run and could slow down the app by increasing bundle size. I wanted to debug this to better understand the root issue, but I’m still unsure why router.replace and router.push felt laggy in the URL bar, even though the input itself was always responsive.

Does anyone have insights on why this is happening or can share best practices for handling this?

Here’s the code for reference:

"use client";

import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";

export default function Search() {
  const [entries, setEntries] = useState([]);

  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const search = searchParams.get("search") || "";

  const [searchText, setSearchText] = useState(search);

  useEffect(() => {
    console.log("rerender");
  });

  // Get a new searchParams string by merging the current
  // searchParams with a provided key/value pair
  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);

      return params.toString();
    },
    [searchParams]
  );

  const handleInputChange = (word: string) => {
    const newPath = pathname + "?" + createQueryString("search", word);
    // Next js router
    router.replace(
      newPath,
      undefined
      // { shallow: true,} // This is no longer supported
    );
    // Vanilla JS history API
    window.history.replaceState(null, "", newPath);
    setSearchText(word);
  };

  // API Call on search
  useEffect(() => {
    const awaitFunc = async (value: string) => {
      const data: any = await apiFunction(value);
      if (!data) {
        setEntries([]);
        return;
      }
      setEntries(data);
    };

    if (search) {
      awaitFunc(search);
    } else {
      setEntries([]);
    }
  }, [search]);

  return (
    <div>
      <Input
        value={searchText}
        onChange={(e) => handleInputChange(e.target.value)}
      />
      {JSON.stringify(entries)}
    </div>
  );
}
like image 617
BenjaminK Avatar asked Sep 14 '25 06:09

BenjaminK


1 Answers

There is nothing wrong in using history.replaceState. The docs state it can be used and it is integrated with Next.js as mentioned in the official documentation.

The reason why router.replace() lags is that useRouter performs more than just changing the URL but also:

  • Updates the router state store
  • Manages component rerendering
  • Manages prefetching and caching
  • Handles route transitions
  • Reconciles React Server Components in the App Router

References:

  • router reducer implementation
    • Native history API won't affect this
  • How Routing and Navigation Works

Also, you are calling the function and reacting to the change on every keystroke which should be avoided. The common solution is to use a debounce function (see lodash.debounce).

As I've mentioned, Next.js integrates with history.replaceState function. Nuqs uses that in its internal Next.js adapter, but there is a catch. Updating it should also be deferred (debounced) as discussed in this issue.

So in short:

  • Use history.replaceState function
  • Debounce the search and the history.replaceState calls

Putting it together:

"use client";

import { useCallback, useEffect, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";

// A simple debounce implementation if you don't want to import lodash
function debounce(func, wait) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
}

export default function Search() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  
  // Get initial search value from URL
  const initialSearch = searchParams.get("search") || "";
  const [searchText, setSearchText] = useState(initialSearch);
  const [entries, setEntries] = useState([]);

  // Create query string util
  const createQueryString = useCallback(
    (name, value) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(name, value);
      } else {
        params.delete(name);
      }
      return params.toString();
    },
    [searchParams]
  );

  // Create debounced URL updater
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const updateURLDebounced = useCallback(
    debounce((value) => {
      const newQueryString = createQueryString("search", value);
      const newPath = pathname + (newQueryString ? `?${newQueryString}` : "");
      
      // Use window.history directly for better performance
      window.history.replaceState(null, "", newPath);
    }, 300),
    [pathname, createQueryString]
  );

  // Handle input changes
  const handleInputChange = (e) => {
    const value = e.target.value;
    setSearchText(value);
    updateURLDebounced(value);
  };

  // Fetch data based on URL changes
  useEffect(() => {
    const search = searchParams.get("search");
    
    const fetchData = async () => {
      if (search) {
        try {
          const data = await apiFunction(search);
          setEntries(data || []);
        } catch (error) {
          console.error("Error fetching data:", error);
          setEntries([]);
        }
      } else {
        setEntries([]);
      }
    };

    fetchData();
  }, [searchParams]);

  return (
    <div>
      <Input
        value={searchText}
        onChange={handleInputChange}
        placeholder="Search..."
      />
      {JSON.stringify(entries)}
    </div>
  );
}

Note that there are even more ways into achieve this. Things to consider:

  • useTransition to control the input state
  • Abort the API call when you make a new one. see

Your question have some confusing points like:

  • what is entries
  • what is apiFunction
  • why control the input, using ref would be even more performatic
  • why not extract the awaitFunc to its own useCallback fn
like image 62
vzsoares Avatar answered Sep 15 '25 20:09

vzsoares