query-params: migrate ChatBox to nuqs and delete hand-rolled hook

Move ChatBox's selectedTab to nuqs useQueryState, inline the search params
directly in the search page, and remove the now-unused generic useQueryParams
hook (and its file). The entire app's URL query-param state is now nuqs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-05-29 04:51:32 -04:00
parent 9ae2f3dc5b
commit 1f94c5495f
3 changed files with 19 additions and 144 deletions
+11 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useConfig } from '@/components/ConfigContext';
import { useSearchQuery } from '@/hooks/useQueryParams';
import { useQueryStates, parseAsString, parseAsInteger, parseAsBoolean } from 'nuqs';
import { useMeshcoreSearch } from '@/hooks/useMeshcoreSearch';
import SearchInput from '@/components/SearchInput';
import SearchResults from '@/components/SearchResults';
@@ -12,7 +12,12 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
function SearchPageContent() {
const { config } = useConfig();
const { query, setQuery, setLimit, setExact, setIsRepeater } = useSearchQuery();
const [query, setQuery] = useQueryStates({
q: parseAsString.withDefault(''),
limit: parseAsInteger.withDefault(50),
exact: parseAsBoolean.withDefault(false),
is_repeater: parseAsBoolean.withDefault(false),
});
const [showFilters, setShowFilters] = useState(false);
// Always use config values for region and lastSeen
@@ -41,7 +46,7 @@ function SearchPageContent() {
};
const handleLimitChange = (limit: number) => {
setLimit(limit);
setQuery({ limit });
};
return (
@@ -61,7 +66,7 @@ function SearchPageContent() {
<div className="mb-6">
<SearchInput
value={query.q}
onChange={setQuery}
onChange={(q) => setQuery({ q })}
placeholder="Search by node name or public key..."
autoFocus
/>
@@ -138,7 +143,7 @@ function SearchPageContent() {
type="checkbox"
id="exact-match"
checked={query.exact}
onChange={(e) => setExact(e.target.checked)}
onChange={(e) => setQuery({ exact: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="exact-match" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
@@ -157,7 +162,7 @@ function SearchPageContent() {
type="checkbox"
id="is-repeater"
checked={query.is_repeater}
onChange={(e) => setIsRepeater(e.target.checked)}
onChange={(e) => setQuery({ is_repeater: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="is-repeater" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
+8 -13
View File
@@ -10,7 +10,7 @@ import { selectorLabel } from "@/lib/regions";
import { useRegionGroups } from "@/hooks/useRegions";
import { useChatMessages } from "@/hooks/useChatMessages";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { useQueryParams } from "@/hooks/useQueryParams";
import { useQueryState, parseAsInteger } from "nuqs";
interface ChatBoxProps {
@@ -25,10 +25,6 @@ interface TabItem {
isAllMessages?: boolean;
}
interface ChatBoxQuery {
selectedTab?: number;
}
export default function ChatBox({
showAllMessagesTab = false,
className = "",
@@ -47,16 +43,15 @@ export default function ChatBox({
: meshcoreKeys;
// Use query params to persist selected tab across navigation
const { query, setParam } = useQueryParams<ChatBoxQuery>({
selectedTab: showAllMessagesTab ? 1 : 0,
});
const [rawSelectedTab, setSelectedTab] = useQueryState(
'selectedTab',
parseAsInteger.withDefault(showAllMessagesTab ? 1 : 0)
);
// Ensure selectedTab is within bounds of available tabs
const rawSelectedTab = query.selectedTab ?? (showAllMessagesTab ? 1 : 0);
const selectedTab = rawSelectedTab >= 0 && rawSelectedTab < allTabs.length
? rawSelectedTab
const selectedTab = rawSelectedTab >= 0 && rawSelectedTab < allTabs.length
? rawSelectedTab
: (showAllMessagesTab ? 1 : 0);
const setSelectedTab = (tabIndex: number) => setParam('selectedTab', tabIndex);
const [minimized, setMinimized] = useState(!startExpanded); // Use startExpanded as default for minimized state
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { useQueryStates, parseAsString, parseAsInteger, parseAsBoolean } from 'nuqs';
export function useQueryParams<T extends Record<string, any>>(defaultValues: T = {} as T) {
const router = useRouter();
const searchParams = useSearchParams();
const [internalState, setInternalState] = useState<T>(() => {
const result = { ...defaultValues };
// Initialize from search params on mount
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
urlParams.forEach((value, key) => {
// Don't auto-convert 'q' (query) parameter to number since it should always be a string
if (key !== 'q' && !isNaN(Number(value)) && value !== '') {
result[key as keyof T] = Number(value) as T[keyof T];
} else {
result[key as keyof T] = value as T[keyof T];
}
});
}
return result;
});
const internalStateRef = useRef(internalState);
internalStateRef.current = internalState;
// Only update internal state when searchParams change from external navigation
// (not from our own updates)
useEffect(() => {
const newState = { ...defaultValues };
searchParams.forEach((value, key) => {
// Don't auto-convert 'q' (query) parameter to number since it should always be a string
if (key !== 'q' && !isNaN(Number(value)) && value !== '') {
newState[key as keyof T] = Number(value) as T[keyof T];
} else {
newState[key as keyof T] = value as T[keyof T];
}
});
// Only update if the state is actually different
const stateChanged = JSON.stringify(newState) !== JSON.stringify(internalStateRef.current);
if (stateChanged) {
setInternalState(newState);
}
}, [searchParams, defaultValues]);
const query = internalState;
const updateQuery = useCallback((updates: Partial<T>) => {
const newState = { ...internalState, ...updates };
setInternalState(newState);
const newSearchParams = new URLSearchParams();
Object.entries(newState).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '' && value !== defaultValues[key as keyof T]) {
newSearchParams.set(key, value.toString());
}
});
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`;
// Use native History API for shallow-like routing
window.history.replaceState(null, '', newUrl);
}, [internalState, defaultValues]);
const setParam = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
updateQuery({ [key]: value } as unknown as Partial<T>);
}, [updateQuery]);
const clearParam = useCallback((key: keyof T) => {
const newState = { ...internalState };
delete newState[key];
setInternalState(newState);
const newSearchParams = new URLSearchParams();
Object.entries(newState).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '' && v !== defaultValues[k as keyof T]) {
newSearchParams.set(k, v.toString());
}
});
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`;
window.history.replaceState(null, '', newUrl);
}, [internalState, defaultValues]);
const clearAll = useCallback(() => {
setInternalState({ ...defaultValues });
window.history.replaceState(null, '', window.location.pathname);
}, [defaultValues]);
return {
query,
updateQuery,
setParam,
clearParam,
clearAll,
};
}
// Search query params, backed by nuqs for type-safe URL state.
export function useSearchQuery() {
const [query, setQuery] = useQueryStates({
q: parseAsString.withDefault(''),
limit: parseAsInteger.withDefault(50),
exact: parseAsBoolean.withDefault(false),
is_repeater: parseAsBoolean.withDefault(false),
});
return {
query,
setQuery: (q: string) => setQuery({ q }),
setLimit: (limit: number) => setQuery({ limit }),
setExact: (exact: boolean) => setQuery({ exact }),
setIsRepeater: (is_repeater: boolean) => setQuery({ is_repeater }),
updateQuery: setQuery,
};
}