Can I use nuqs to sync the url value between selected store values and url? #1150
-
|
I currently am using a custom hook with 2 main functionalities:
Do nuqs provides way to do it? Or would I need to create my own wrapper on top of nuqs |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 2 replies
-
|
Current hook if anyone is interested: 'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useMemo, useRef } from 'react'
import type { ZodType } from 'zod'
export interface UseQueryParamSyncOptions<T> {
key: string
value: T
setValue?: (nextValue: T) => void
/**
* Debounce delay in milliseconds for URL updates. Default: 300ms
*/
debounceMs?: number
/**
* Whether to hydrate the value only once on mount (true) or on every URL change (false).
* - `true`: Hydrate once on mount, ignore subsequent URL changes
* - `false` (default): Hydrate on every URL parameter change
*/
hydrateOnce?: boolean
/**
* Whether to use router.replace() (true) or router.push() (false) for URL updates. Default: true
*/
replace?: boolean
/**
* Whether to scroll to top when updating URL. Default: false
*/
scroll?: boolean
/**
* Custom serialization function. Default: converts value to string or null
*/
serialize?: (value: T) => string | null | undefined
/**
* Custom deserialization function. Default: returns raw string as-is
*/
deserialize?: (raw: string | null) => T | undefined
/**
* Function to determine if the value should be deleted from URL.
* Default: deletes empty strings
*/
shouldDelete?: (value: T) => boolean
/**
* Function to determine if the value should persist across page navigation.
* return true to cache the value for navigation.
*/
shouldFollowNavigation?: (value: T, path: string) => boolean
/**
* Whether the hook should be enabled. When false, no URL syncing occurs.
* @default true
*/
enabled?: boolean
/**
* Function to validate if a value is valid. If validation fails, `defaultValue` is used.
* Called with the parsed value - return truthy for valid, falsy for invalid.
*/
validate?: (value: T) => unknown
/**
* Default value to use when validation fails or no value is present.
*/
defaultValue?: T
/**
* Called once after the initial value is resolved and applied.
* Fires on the first successful hydration from URL.
*/
onInitialSync?: (
value: T,
meta: { source: 'url' | 'default' | 'navigation' },
) => void
}
// Module-level cache for navigation values
const navigationCache = new Map<string, string>()
/**
* Sync a single value with a URL query parameter.
*
* Features:
* - Bidirectional sync between state and URL query parameters
* - Debounced URL updates to prevent excessive navigation
* - Optional navigation persistence with `shouldFollowNavigation`
* - Configurable hydration behavior with `hydrateOnce`
* - Enable/disable functionality with `enabled` prop
* - Custom serialization/deserialization support (there are helpers too!)
*/
export function useQueryParamSync<T>({
key,
value,
setValue,
debounceMs = 300,
hydrateOnce = false,
replace = true,
scroll = false,
serialize,
deserialize,
shouldDelete,
shouldFollowNavigation,
enabled = true,
validate,
defaultValue,
onInitialSync,
}: UseQueryParamSyncOptions<T>) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const rawParamValue = searchParams?.get(key)
const baseParamsString = searchParams?.toString() ?? ''
const previousPathnameRef = useRef(pathname)
const previousUrlRawValueRef = useRef<string | null>(null)
const hasHydratedRef = useRef(false)
const skipNextSyncRef = useRef(false)
const didInitialSyncRef = useRef(false)
const serializeFn = useMemo<
NonNullable<UseQueryParamSyncOptions<T>['serialize']>
>(
() => serialize ?? ((v: T) => (v == null ? null : (v as string))),
[serialize],
)
const deserializeFn = useMemo<
NonNullable<UseQueryParamSyncOptions<T>['deserialize']>
>(() => deserialize ?? ((raw: string | null) => raw as T), [deserialize])
const shouldDeleteFn = useMemo<
NonNullable<UseQueryParamSyncOptions<T>['shouldDelete']>
>(
() =>
shouldDelete ??
((v: T) => v == null || (typeof v === 'string' && (v as string) === '')),
[shouldDelete],
)
// Sync from URL: hydration and navigation restore
useEffect(() => {
if (!enabled || !setValue || !deserializeFn) return
const previousPathname = previousPathnameRef.current
const didNavigationHappen = previousPathname !== pathname
previousPathnameRef.current = pathname
// Hydration: sync from URL
if (!didNavigationHappen) {
// for handling sync from URL to state only
// we don't use it anywhere else since
// we want to store here only the url value
const handleSetValue = (raw: string | null, nextValue: T) => {
previousUrlRawValueRef.current = raw
setValue(nextValue)
}
// If hydrateOnce is true, only hydrate on first mount
// If hydrateOnce is false, hydrate on every URL change
const shouldHydrate = hydrateOnce ? !hasHydratedRef.current : true
if (shouldHydrate && rawParamValue != null) {
hasHydratedRef.current = true
// Prevent the next sync from overwriting URL state before hydration applies.
skipNextSyncRef.current = true
if (rawParamValue === previousUrlRawValueRef.current) {
skipNextSyncRef.current = false
return
}
const parsed = deserializeFn(rawParamValue)
// Validate the parsed value if validation function is provided
if (parsed == null || (validate && !validate(parsed))) {
// Use defaultValue if validation fails
if (defaultValue !== undefined) {
handleSetValue(rawParamValue, defaultValue)
if (onInitialSync && !didInitialSyncRef.current) {
onInitialSync(defaultValue, { source: 'default' })
didInitialSyncRef.current = true
}
}
} else {
handleSetValue(rawParamValue, parsed)
if (onInitialSync && !didInitialSyncRef.current) {
onInitialSync(parsed, { source: 'url' })
didInitialSyncRef.current = true
}
}
skipNextSyncRef.current = false
return
}
}
// Navigation: restore from cache when navigating to page without param
if (didNavigationHappen && shouldFollowNavigation) {
// if there is already current param, we don't need to restore from cache
if (rawParamValue !== null) {
return
}
let restored = false
const cachedValue = navigationCache.get(key)
if (cachedValue) {
const parsed = deserializeFn(cachedValue)
if (parsed !== undefined && parsed !== null) {
// We know pathname is not null at this point
if (shouldFollowNavigation(parsed, pathname!)) {
// Validate the cached value if validation function is provided
if (validate && !validate(parsed)) {
// Use defaultValue if validation fails
if (defaultValue !== undefined) {
setValue(defaultValue)
}
} else {
setValue(parsed)
restored = true
}
}
}
}
// No cached value or it was invalid: fall back to default on required pages
if (!restored && defaultValue !== undefined) {
setValue(defaultValue)
}
}
}, [
enabled,
key,
hydrateOnce,
rawParamValue,
setValue,
deserializeFn,
pathname,
shouldFollowNavigation,
validate,
defaultValue,
onInitialSync,
])
// Ongoing sync to URL
useEffect(() => {
if (!enabled || skipNextSyncRef.current) return
const id = setTimeout(() => {
const params = new URLSearchParams(baseParamsString)
const current = params.get(key)
const nextStr = serializeFn(value)
const deleteIt = shouldDeleteFn(value)
let changed = false
if (deleteIt) {
if (params.has(key)) {
params.delete(key)
changed = true
}
} else if (nextStr !== current) {
if (nextStr == null) {
if (params.has(key)) {
params.delete(key)
changed = true
}
} else {
params.set(key, nextStr)
changed = true
}
}
if (changed) {
const url = `${pathname}?${params.toString()}`
if (replace) router.replace(url, { scroll })
else router.push(url, { scroll })
}
}, debounceMs)
return () => clearTimeout(id)
}, [
enabled,
debounceMs,
key,
pathname,
replace,
router,
scroll,
baseParamsString,
serializeFn,
shouldDeleteFn,
value,
shouldFollowNavigation,
])
// Cache the value when it changes (if should follow navigation)
useEffect(() => {
if (!enabled || value == null) {
navigationCache.delete(key)
return
}
// We know pathname is not null at this point
if (shouldFollowNavigation?.(value, pathname!)) {
const serialized = serializeFn(value)
if (serialized) {
navigationCache.set(key, serialized)
}
} else {
navigationCache.delete(key)
}
}, [enabled, value, key, shouldFollowNavigation, serializeFn, pathname])
}
// helpers for arrays
export const serializeCsv = (v: string[] | null | undefined) =>
!v || v.length === 0 ? null : v.join(',')
export const deserializeCsv = (raw: string | null) =>
!raw
? []
: raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
export const isEmptyArray = (v: unknown) =>
Array.isArray(v) ? v.length === 0 : v == null
// helpers for JSON values
export const serializeJson = <T>(value: T | null | undefined) =>
value == null ? null : JSON.stringify(value)
export const deserializeJson = <T>(raw: string | null): T | undefined => {
if (!raw) return undefined
try {
return JSON.parse(raw) as T
} catch {
return undefined
}
}
export const deserializeJsonWithSchema = <T>(
raw: string | null,
schema: ZodType<T>,
): T | undefined => {
const value = deserializeJson<unknown>(raw)
if (value === undefined) return undefined
const result = schema.safeParse(value)
// hook will use default value if undefined is returned
return result.success ? result.data : undefined
}
// Curried helpers to compose options for useQueryParamSync
export const createJsonParam = <T>() => ({
serialize: (value: T | null | undefined) => serializeJson<T>(value),
deserialize: (raw: string | null) => deserializeJson<T>(raw),
shouldDelete: (value: T | null | undefined) => value == null,
})
export const createJsonParamWithSchema = <T>(schema: ZodType<T>) => ({
serialize: (value: T | null | undefined) => serializeJson<T>(value),
deserialize: (raw: string | null) =>
deserializeJsonWithSchema<T>(raw, schema),
shouldDelete: (value: T | null | undefined) => value == null,
}) |
Beta Was this translation helpful? Give feedback.
-
|
Your approach is interesting, nuqs covers almost all of what your API exposes, apart from the What's the purpose behind |
Beta Was this translation helpful? Give feedback.
-
|
Locking this conversation as it's getting targeted by spammers for some reason. 😓 |
Beta Was this translation helpful? Give feedback.
Current hook if anyone is interested: