I’m working on a filtering system in a Next.js 13+ (App Router) app. I found a hook in another project, improved it for my needs, and it seems to work well — but I still have some concerns about its correctness, performance, and long-term maintainability
What I’m unsure about:
Am I using useMemo and useCallback properly?
Is it safe to do Object.fromEntries(searchParams)?
Any potential issues with URLSearchParams or router.replace()?
Could this design cause performance or readability issues over time?
Would really appreciate feedback or best practices from more experienced Next.js users
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useCallback } from "react";
const identity = (value) => value;
const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
export const useSearchParamsApi = () => {
const router = useRouter();
const searchParams = useSearchParams();
const record = useMemo(
() => Object.fromEntries(searchParams),
[searchParams]
);
const replace = useCallback(
(value, params) => {
router.replace(`?${new URLSearchParams(value)}`, {
scroll: false,
...params,
});
},
[router]
);
const update = useCallback(
(value, params) => {
const next = { ...record, ...value };
if (!("page" in value)) {
next.page = "1";
}
const cleaned = Object.fromEntries(
Object.entries(next).filter(
([_, v]) => v !== undefined && v !== null && v !== ""
)
);
replace(cleaned, params);
},
[replace, record]
);
return { record, searchParams, replace, update };
};
export function useFiltersQuery() {
const { record, update, replace, searchParams } = useSearchParamsApi();
const valueFilter = (name, fallback, deserialize = identity) => ({
value: useMemo(() => {
return record[name] ? deserialize(record[name]) : fallback;
}, [record, name]),
update: useCallback(
(next) => update({ [name]: String(next) }),
[name, update]
),
remove: useCallback(() => update({ [name]: undefined }), [name, update]),
hasFilter: searchParams.has(name),
});
const arrayFilter = (name, defaultValue = []) => {
const field = valueFilter(name, defaultValue, (v) => v.split(","));
return {
...field,
toggle: useCallback(
(value) => field.update(toggle(field.value, value)),
[field.value, field.update]
),
};
};
const clearFilter = useCallback(
(key, params) => {
update(
{
[key]: undefined,
page: "1",
},
params
);
},
[update]
);
const resetFilters = useCallback(
(params) => {
replace({}, params);
},
[replace]
);
return {
record,
valueFilter,
arrayFilter,
clearFilter,
resetFilters,
};
}
What I tried and what I expected:
I used this hook across several filtered pages — for example, a companies catalog, where filters like categories, languages, and service type are stored in the URL. It works as expected:
Filters show up in the UI and sync with the URL.
I can add/remove filters and the page rerenders correctly.
When I change a filter, page resets to 1 automatically — which is what I want.
What I expected:
A reusable, declarative solution for handling filters without local state. It seems to be working well, but I’d like feedback on:
Whether this may cause issues down the road,
And whether there are any anti-patterns I might be missing.