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>
);
}
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:
References:
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:
history.replaceState
functionhistory.replaceState
callsPutting 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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With