Skip to content

Commit

Permalink
feat: Add useQueryStates for multiple keys sync
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 committed Oct 15, 2020
1 parent 84e70bb commit 0591503
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 103 deletions.
37 changes: 37 additions & 0 deletions src/defs.ts
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()
}
}
106 changes: 3 additions & 103 deletions src/index.ts
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'
101 changes: 101 additions & 0 deletions src/useQueryState.ts
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]
}
124 changes: 124 additions & 0 deletions src/useQueryStates.ts
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]
}

0 comments on commit 0591503

Please sign in to comment.