Skip to content

Commit

Permalink
feat: Add "clear on default" option (#493)
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 authored Feb 11, 2024
1 parent cd2ca85 commit df5e894
Show file tree
Hide file tree
Showing 8 changed files with 97 additions and 12 deletions.
2 changes: 1 addition & 1 deletion packages/docs/content/docs/basic-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const clearCount = () => setCount(null) // Remove query from the URL

<Callout title="Note">
The default value is internal to React, it will **not** be written to the
URL.
URL _unless you set it explicitly_.
</Callout>

<Callout title="Tip">
Expand Down
34 changes: 27 additions & 7 deletions packages/docs/content/docs/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Call-level options will override hook level options.
By default, state updates are done by replacing the current history entry with
the updated query when state changes.

You can see this as a sort of `git squash`, where all state-changing
You can see this as a sort of `git squash{:shell}`, where all state-changing
operations are merged into a single history value.

You can also opt-in to push a new history item for each state change,
Expand Down Expand Up @@ -64,11 +64,11 @@ _-- "With great power comes great responsibility."_
By default, query state updates are done in a _client-first_ manner: there are
no network calls to the server.

This is equivalent to the `shallow` option of the Next.js router set to `true`.
This is equivalent to the `shallow` option of the Next.js router set to `true{:ts}`.

To opt-in to query updates notifying the server (to re-run `getServerSideProps`
in the pages router and re-render Server Components on the app router),
you can set `shallow` to `false`:
you can set `shallow` to `false{:ts}`:

```ts /shallow: false/
useQueryState('foo', { shallow: false })
Expand Down Expand Up @@ -99,7 +99,7 @@ Safari's rate limits are much higher and require a throttle of 120ms (320ms for
versions of Safari).

If you want to opt-in to a larger throttle time -- for example to reduce the amount
of requests sent to the server when paired with `shallow: false` -- you can
of requests sent to the server when paired with `shallow: false{:ts}` -- you can
specify it under the `throttleMs` option:

```ts /throttleMs: 1000/
Expand All @@ -118,19 +118,19 @@ the highest value will be used. Also, values lower than 50ms will be ignored,
to avoid rate-limiting issues.
[Read more](https://francoisbest.com/posts/2023/storing-react-state-in-the-url-with-nextjs#batching--throttling).

Specifying a `+Infinity` value for `throttleMs` will **disable** updates to the
Specifying a `+Infinity{:ts}` value for `throttleMs` will **disable** updates to the
URL or the server, which means `useQueryState` will behave essentially like
`React.useState`.


## Transitions

When combined with `shallow: false`, you can use React's `useTransition` hook
When combined with `shallow: false{:ts}`, you can use React's `useTransition` hook
to get loading states while the server is re-rendering server components with
the updated URL.

Pass in the `startTransition` function from `useTransition` to the options
to enable this behaviour _(this will set `shallow: false` automatically for you)_:
to enable this behaviour _(this will set `shallow: false{:ts}` automatically for you)_:

```tsx /startTransition/1,3#2
'use client'
Expand All @@ -156,3 +156,23 @@ function ClientComponent({ data }) {
return <div>...</div>
}
```

## Clear on default

By default, when the state is set to the default value, the search parameter is
**not** removed from the URL, and is reflected explicitly. This is because
**default values _can_ change**, and the meaning of the URL along with it.

If you want to remove the search parameter from the URL when it's set to the default
value, you can set `clearOnDefault` to `true{:ts}`:

```ts /clearOnDefault: true/
useQueryState('search', {
defaultValue: '',
clearOnDefault: true
})
```

<Callout title="Tip">
Clearing the key-value pair from the query string can always be done by setting the state to `null{:ts}`.
</Callout>
8 changes: 8 additions & 0 deletions packages/e2e/cypress/e2e/clearOnDefault.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="cypress" />

it('Clears the URL when setting the default value when `clearOnDefault` is used', () => {
cy.visit('/app/clearOnDefault?a=a&b=b')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('button').click()
cy.location('search').should('eq', '?a=')
})
32 changes: 32 additions & 0 deletions packages/e2e/src/app/app/clearOnDefault/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import { useQueryState } from 'nuqs'
import { Suspense } from 'react'

export default function Page() {
return (
<Suspense>
<Client />
</Suspense>
)
}

function Client() {
const [, setA] = useQueryState('a')
const [, setB] = useQueryState('b', {
defaultValue: '',
clearOnDefault: true
})
return (
<>
<button
onClick={() => {
setA('')
setB('')
}}
>
Clear
</button>
</>
)
}
9 changes: 9 additions & 0 deletions packages/nuqs/src/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export type Options<Shallow = unknown> = {
* in the same Options object.
*/
startTransition?: StartTransition<Shallow>

/**
* Clear the key-value pair from the URL query string when setting the state
* to the default value.
*
* Defaults to `false` to keep backwards-compatiblity when the default value
* changes (prefer explicit URLs whose meaning don't change).
*/
clearOnDefault?: boolean
}

export type Nullable<T> = {
Expand Down
4 changes: 3 additions & 1 deletion packages/nuqs/src/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle()

type UpdateMap = Map<string, string | null>
const updateQueue: UpdateMap = new Map()
const queueOptions: Required<Omit<Options, 'startTransition'>> = {
const queueOptions: Required<
Omit<Options, 'startTransition' | 'clearOnDefault'>
> = {
history: 'replace',
scroll: false,
shallow: true,
Expand Down
11 changes: 9 additions & 2 deletions packages/nuqs/src/useQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export function useQueryState<T = string>(
parse = x => x as unknown as T,
serialize = String,
defaultValue = undefined,
clearOnDefault = false,
startTransition
}: Partial<UseQueryStateOptions<T>> & {
defaultValue?: T
Expand All @@ -215,6 +216,7 @@ export function useQueryState<T = string>(
throttleMs: FLUSH_RATE_LIMIT_MS,
parse: x => x as unknown as T,
serialize: String,
clearOnDefault: false,
defaultValue: undefined
}
) {
Expand Down Expand Up @@ -278,10 +280,15 @@ export function useQueryState<T = string>(

const update = React.useCallback(
(stateUpdater: React.SetStateAction<T | null>, options: Options = {}) => {
const newValue: T | null = isUpdaterFunction(stateUpdater)
let newValue: T | null = isUpdaterFunction(stateUpdater)
? stateUpdater(stateRef.current ?? defaultValue ?? null)
: stateUpdater

if (
(options.clearOnDefault || clearOnDefault) &&
newValue === defaultValue
) {
newValue = null
}
// Sync all hooks state (including this one)
emitter.emit(key, newValue)
enqueueQueryStringUpdate(key, newValue, serialize, {
Expand Down
9 changes: 8 additions & 1 deletion packages/nuqs/src/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
scroll = false,
shallow = true,
throttleMs = FLUSH_RATE_LIMIT_MS,
clearOnDefault = false,
startTransition
}: Partial<UseQueryStatesOptions> = {}
): UseQueryStatesReturn<KeyMap> {
Expand Down Expand Up @@ -145,11 +146,17 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
? stateUpdater(stateRef.current)
: stateUpdater
debug('[nuq+ `%s`] setState: %O', keys, newState)
for (const [key, value] of Object.entries(newState)) {
for (let [key, value] of Object.entries(newState)) {
const config = keyMap[key]
if (!config) {
continue
}
if (
(options.clearOnDefault || clearOnDefault) &&
value === config.defaultValue
) {
value = null
}
emitter.emit(key, value)
enqueueQueryStringUpdate(key, value, config.serialize ?? String, {
// Call-level options take precedence over hook declaration options.
Expand Down

0 comments on commit df5e894

Please sign in to comment.