generated from chiffre-io/template-library
-
-
Notifications
You must be signed in to change notification settings - Fork 101
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add useQueryStates for multiple keys sync
- Loading branch information
Showing
4 changed files
with
265 additions
and
103 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
export type HistoryOptions = 'replace' | 'push' | ||
|
||
export type Serializers<T> = { | ||
parse: (value: string) => T | null | ||
serialize: (value: T) => string | ||
} | ||
|
||
export type QueryTypeMap = { | ||
string: Serializers<string> | ||
integer: Serializers<number> | ||
float: Serializers<number> | ||
timestamp: Serializers<Date> | ||
isoDateTime: Serializers<Date> | ||
} | ||
|
||
export const queryTypes: QueryTypeMap = { | ||
string: { | ||
parse: v => v, | ||
serialize: v => `${v}` | ||
}, | ||
integer: { | ||
parse: v => parseInt(v), | ||
serialize: v => Math.round(v).toFixed() | ||
}, | ||
float: { | ||
parse: v => parseFloat(v), | ||
serialize: v => v.toString() | ||
}, | ||
timestamp: { | ||
parse: v => new Date(v), | ||
serialize: (v: Date) => v.valueOf().toString() | ||
}, | ||
isoDateTime: { | ||
parse: v => new Date(v), | ||
serialize: (v: Date) => v.toISOString() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,103 +1,3 @@ | ||
import React from 'react' | ||
import { useRouter } from 'next/router' | ||
|
||
export interface UseQueryStateOptions<T> { | ||
/** | ||
* The operation to use on state updates. Defaults to `replace`. | ||
*/ | ||
history: 'replace' | 'push' | ||
|
||
parse: (value: string) => T | null | ||
serialize: (value: T) => string | ||
} | ||
|
||
export type UseQueryStateReturn<T> = [ | ||
T | null, | ||
React.Dispatch<React.SetStateAction<T>> | ||
] | ||
|
||
/** | ||
* React state hook synchronized with a URL query string in Next.js | ||
* | ||
* @param key - The URL query string key to bind to | ||
*/ | ||
export function useQueryState<T = string>( | ||
key: string, | ||
{ | ||
history = 'replace', | ||
parse = x => (x as unknown) as T, | ||
serialize = x => `${x}` | ||
}: Partial<UseQueryStateOptions<T>> = {} | ||
): UseQueryStateReturn<T | null> { | ||
const router = useRouter() | ||
|
||
// Memoizing the update function has the advantage of making it | ||
// immutable as long as `history` stays the same. | ||
// It reduces the amount of reactivity needed to update the state. | ||
const updateUrl = React.useMemo( | ||
() => (history === 'push' ? router.push : router.replace), | ||
[history] | ||
) | ||
|
||
const getValue = React.useCallback((): T | null => { | ||
if (typeof window === 'undefined') { | ||
// Not available in an SSR context | ||
return null | ||
} | ||
const query = new URLSearchParams(window.location.search) | ||
const value = query.get(key) | ||
return value ? parse(value) : null | ||
}, []) | ||
|
||
// Update the state value only when the relevant key changes. | ||
// Because we're not calling getValue in the function argument | ||
// of React.useMemo, but instead using it as the function to call, | ||
// there is no need to pass it in the dependency array. | ||
const value = React.useMemo(getValue, [router.query[key]]) | ||
|
||
const update = React.useCallback( | ||
(stateUpdater: React.SetStateAction<T | null>) => { | ||
const isUpdaterFunction = ( | ||
input: any | ||
): input is (prevState: T | null) => T | null => { | ||
return typeof input === 'function' | ||
} | ||
|
||
// Resolve the new value based on old value & updater | ||
const oldValue = getValue() | ||
const newValue = isUpdaterFunction(stateUpdater) | ||
? stateUpdater(oldValue) | ||
: stateUpdater | ||
// We can't rely on router.query here to avoid causing | ||
// unnecessary renders when other query parameters change. | ||
// URLSearchParams is already polyfilled by Next.js | ||
const query = new URLSearchParams(window.location.search) | ||
if (newValue) { | ||
query.set(key, serialize(newValue)) | ||
} else { | ||
// Don't leave value-less keys hanging | ||
query.delete(key) | ||
} | ||
|
||
// Remove fragment and query from asPath | ||
// router.pathname includes dynamic route keys, rather than the route itself, | ||
// e.g. /views/[view] rather than /views/my-view | ||
const [asPath] = router.asPath.split(/\?|#/, 1) | ||
updateUrl?.call( | ||
router, | ||
{ | ||
pathname: router.pathname, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
}, | ||
{ | ||
pathname: asPath, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
} | ||
) | ||
}, | ||
[key, updateUrl] | ||
) | ||
return [value, update] | ||
} | ||
export * from './defs' | ||
export * from './useQueryState' | ||
export * from './useQueryStates' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import React from 'react' | ||
import { useRouter } from 'next/router' | ||
import { HistoryOptions, Serializers } from './defs' | ||
|
||
export interface UseQueryStateOptions<T> extends Serializers<T> { | ||
/** | ||
* The operation to use on state updates. Defaults to `replace`. | ||
*/ | ||
history: HistoryOptions | ||
} | ||
|
||
export type UseQueryStateReturn<T> = [ | ||
T | null, | ||
React.Dispatch<React.SetStateAction<T>> | ||
] | ||
|
||
/** | ||
* React state hook synchronized with a URL query string in Next.js | ||
* | ||
* @param key - The URL query string key to bind to | ||
*/ | ||
export function useQueryState<T = string>( | ||
key: string, | ||
{ | ||
history = 'replace', | ||
parse = x => (x as unknown) as T, | ||
serialize = x => `${x}` | ||
}: Partial<UseQueryStateOptions<T>> = {} | ||
): UseQueryStateReturn<T | null> { | ||
const router = useRouter() | ||
|
||
// Memoizing the update function has the advantage of making it | ||
// immutable as long as `history` stays the same. | ||
// It reduces the amount of reactivity needed to update the state. | ||
const updateUrl = React.useMemo( | ||
() => (history === 'push' ? router.push : router.replace), | ||
[history] | ||
) | ||
|
||
const getValue = React.useCallback((): T | null => { | ||
if (typeof window === 'undefined') { | ||
// Not available in an SSR context | ||
return null | ||
} | ||
const query = new URLSearchParams(window.location.search) | ||
const value = query.get(key) | ||
return value ? parse(value) : null | ||
}, []) | ||
|
||
// Update the state value only when the relevant key changes. | ||
// Because we're not calling getValue in the function argument | ||
// of React.useMemo, but instead using it as the function to call, | ||
// there is no need to pass it in the dependency array. | ||
const value = React.useMemo(getValue, [router.query[key]]) | ||
|
||
const update = React.useCallback( | ||
(stateUpdater: React.SetStateAction<T | null>) => { | ||
const isUpdaterFunction = ( | ||
input: any | ||
): input is (prevState: T | null) => T | null => { | ||
return typeof input === 'function' | ||
} | ||
|
||
// Resolve the new value based on old value & updater | ||
const oldValue = getValue() | ||
const newValue = isUpdaterFunction(stateUpdater) | ||
? stateUpdater(oldValue) | ||
: stateUpdater | ||
// We can't rely on router.query here to avoid causing | ||
// unnecessary renders when other query parameters change. | ||
// URLSearchParams is already polyfilled by Next.js | ||
const query = new URLSearchParams(window.location.search) | ||
if (newValue) { | ||
query.set(key, serialize(newValue)) | ||
} else { | ||
// Don't leave value-less keys hanging | ||
query.delete(key) | ||
} | ||
|
||
// Remove fragment and query from asPath | ||
// router.pathname includes dynamic route keys, rather than the route itself, | ||
// e.g. /views/[view] rather than /views/my-view | ||
const [asPath] = router.asPath.split(/\?|#/, 1) | ||
updateUrl?.call( | ||
router, | ||
{ | ||
pathname: router.pathname, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
}, | ||
{ | ||
pathname: asPath, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
} | ||
) | ||
}, | ||
[key, updateUrl] | ||
) | ||
return [value, update] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import React from 'react' | ||
import { useRouter } from 'next/router' | ||
import { HistoryOptions, Serializers } from './defs' | ||
|
||
export type UseQueryStatesKeysMap<T> = { | ||
[K in keyof T]: Serializers<T[K]> | ||
} | ||
|
||
export interface UseQueryStatesOptions { | ||
/** | ||
* The operation to use on state updates. Defaults to `replace`. | ||
*/ | ||
history: HistoryOptions | ||
} | ||
|
||
export type Values<T> = { | ||
[K in keyof T]: T[K] | null | ||
} | ||
|
||
export type SetValues<T> = React.Dispatch< | ||
React.SetStateAction<Partial<Values<T>>> | ||
> | ||
|
||
export type UseQueryStatesReturn<T> = [Values<T>, SetValues<T>] | ||
|
||
/** | ||
* Synchronise multiple query string arguments to React state in Next.js | ||
* | ||
* @param keys - An object describing the keys to synchronise and how to | ||
* serialise and parse them. | ||
* Use `queryTypes.(string|integer|float)` for quick shorthands. | ||
*/ | ||
export function useQueryStates<T extends object>( | ||
keys: UseQueryStatesKeysMap<T>, | ||
{ history = 'replace' }: Partial<UseQueryStatesOptions> = {} | ||
): UseQueryStatesReturn<T> { | ||
const router = useRouter() | ||
|
||
// Memoizing the update function has the advantage of making it | ||
// immutable as long as `history` stays the same. | ||
// It reduces the amount of reactivity needed to update the state. | ||
const updateUrl = React.useMemo( | ||
() => (history === 'push' ? router.push : router.replace), | ||
[history] | ||
) | ||
|
||
const getValues = React.useCallback((): Values<T> => { | ||
if (typeof window === 'undefined') { | ||
// Not available in an SSR context, return all null | ||
return Object.keys(keys).reduce( | ||
(obj, key) => ({ ...obj, [key]: null }), | ||
{} as Values<T> | ||
) | ||
} | ||
const query = new URLSearchParams(window.location.search) | ||
return Object.keys(keys).reduce((values, key) => { | ||
const { parse } = keys[key as keyof T] | ||
const value = query.get(key) | ||
return { | ||
...values, | ||
[key]: value ? parse(value) : null | ||
} | ||
}, {} as Values<T>) | ||
}, [keys]) | ||
|
||
// Update the state values only when the relevant keys change. | ||
// Because we're not calling getValues in the function argument | ||
// of React.useMemo, but instead using it as the function to call, | ||
// there is no need to pass it in the dependency array. | ||
const values = React.useMemo( | ||
getValues, | ||
Object.keys(keys).map(key => router.query[key]) | ||
) | ||
|
||
const update = React.useCallback( | ||
(stateUpdater: React.SetStateAction<Partial<Values<T>>>) => { | ||
const isUpdaterFunction = ( | ||
input: any | ||
): input is (prevState: Partial<Values<T>>) => Partial<Values<T>> => { | ||
return typeof input === 'function' | ||
} | ||
|
||
// Resolve the new values based on old values & updater | ||
const oldValues = getValues() | ||
const newValues = isUpdaterFunction(stateUpdater) | ||
? stateUpdater(oldValues) | ||
: stateUpdater | ||
// We can't rely on router.query here to avoid causing | ||
// unnecessary renders when other query parameters change. | ||
// URLSearchParams is already polyfilled by Next.js | ||
const query = new URLSearchParams(window.location.search) | ||
|
||
Object.keys(newValues).forEach(key => { | ||
const newValue = newValues[key as keyof T] | ||
if (newValue) { | ||
const { serialize } = keys[key as keyof T] | ||
query.set(key, serialize(newValue as T[keyof T])) | ||
} else { | ||
query.delete(key) | ||
} | ||
}) | ||
|
||
// Remove fragment and query from asPath | ||
// router.pathname includes dynamic route keys, rather than the route itself, | ||
// e.g. /views/[view] rather than /views/my-view | ||
const [asPath] = router.asPath.split(/\?|#/, 1) | ||
updateUrl?.call( | ||
router, | ||
{ | ||
pathname: router.pathname, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
}, | ||
{ | ||
pathname: asPath, | ||
hash: window.location.hash, | ||
search: query.toString() | ||
} | ||
) | ||
}, | ||
[keys, updateUrl] | ||
) | ||
return [values, update] | ||
} |