Skip to content

feat(utils): Add useQueryStateWithLocalStorage hook#106625

Closed
gggritso wants to merge 6 commits into
masterfrom
georgegritsouk/dain-1161-add-hook-for-state-driven-by-url-query-parameters-falling
Closed

feat(utils): Add useQueryStateWithLocalStorage hook#106625
gggritso wants to merge 6 commits into
masterfrom
georgegritsouk/dain-1161-add-hook-for-state-driven-by-url-query-parameters-falling

Conversation

@gggritso

@gggritso gggritso commented Jan 20, 2026

Copy link
Copy Markdown
Member

Adds a reusable hook that syncs state between URL query parameters and localStorage. URL takes precedence (for sharing), with localStorage providing session persistence.

Supports any data type via Nuqs parsers (strings, integers, booleans, arrays, etc.).

API

const [pageSize, setPageSize] = useQueryStateWithLocalStorage({
  queryKey: 'sort',
  localStorageKey: 'dashboards:sort',
  defaultValue: 50,
  parser: parseAsInteger,
});

Creates URL param (e.g., ?sort=date) and localStorage key (e.g., dashboards:sort).

Use Cases

  • Filter/sort preferences needing both persistence and URL shareability
  • View modes, page sizes, or toggle states
  • Any state benefiting from both localStorage and URL sharing

Gotchas

The only major gotcha is defaultValue. Nuqs has a .withDefault API, but we cannot allow using it here. If a parser has .withDefault it'll never return null and we don't know when to fall back to localStorage. I figured that a defaultValue is a natural enough API in React land, and we have error checking around the gotcha, and an explanation.

…orage state sync

This hook combines Nuqs (URL query parameters) with localStorage to provide
persistent state that can also be shared via URL.

Features:
- URL query parameter takes precedence (enables sharing)
- Falls back to localStorage when URL param is absent
- Falls back to default value if neither exists
- Single setter updates both URL and localStorage
- Automatic localStorage key construction: namespace:key

Use cases:
- User preferences that should persist across sessions
- Filters/sort options that should be shareable via URL
- Any state that benefits from both persistence and shareability

Example usage:
```typescript
const [sortBy, setSortBy] = useQueryStateWithLocalStorage(
  'releaseFilterSort',  // URL param name
  'dashboards',         // localStorage namespace
  'date'                // default value
);
```

This creates:
- URL param: ?releaseFilterSort=date
- localStorage key: dashboards:releaseFilterSort

Tests:
- 7 comprehensive test cases covering all scenarios
- Uses official Nuqs testing adapter
- Verifies URL/localStorage sync behavior
Change from three positional arguments to a single props object for better
clarity and easier future extension.

Before:
```typescript
useQueryStateWithLocalStorage(key, namespace, defaultValue)
```

After:
```typescript
useQueryStateWithLocalStorage({key, namespace, defaultValue})
```

Benefits:
- Named parameters improve readability
- Order doesn't matter
- Easier to add optional parameters in the future
- More consistent with modern React patterns
@linear

linear Bot commented Jan 20, 2026

Copy link
Copy Markdown

@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Jan 20, 2026
@gggritso gggritso requested a review from a team January 20, 2026 22:05
cursor[bot]

This comment was marked as outdated.

Extend the hook to support any data type via Nuqs parsers, matching the
Nuqs API where parser is optional and defaults to parseAsString.

Changes:
- Add optional 'parser' parameter that accepts any Nuqs Parser<T>
- Defaults to parseAsString for backward compatibility
- Remove 'T extends string' constraint - now supports any type
- Parser handles URL serialization/deserialization
- localStorage uses JSON for storage (supports all JS types)
- Update docs with examples for string, integer, and boolean types
- Update tests: some use default parser, others explicitly test types
- Add 3 new tests for integer and boolean parsers

Example usage:
```typescript
// Strings (parser optional, defaults to parseAsString)
const [sort, setSort] = useQueryStateWithLocalStorage({
  key: 'sort',
  namespace: 'dashboards',
  defaultValue: 'date',
});

// Integers (explicit parser)
const [pageSize, setPageSize] = useQueryStateWithLocalStorage({
  key: 'pageSize',
  namespace: 'dashboards',
  defaultValue: 50,
  parser: parseAsInteger,
});

// Booleans (explicit parser)
const [enabled, setEnabled] = useQueryStateWithLocalStorage({
  key: 'enabled',
  namespace: 'dashboards',
  defaultValue: false,
  parser: parseAsBoolean,
});
```

All 10 tests passing ✓
cursor[bot]

This comment was marked as outdated.

…omparison

Use parser's equality check method when available for more accurate
comparison of complex values like arrays and objects. Falls back to
strict equality when parser.eq is not defined.

Also adds Knip exclusion since this hook is intentionally only used
in test files.
cursor[bot]

This comment was marked as outdated.

Fix bug where empty strings in localStorage were incorrectly treated as
missing values due to truthy check. Use defined() helper to properly
distinguish between null (key doesn't exist) and empty string (key
exists with empty value).

Also add tests to verify empty string handling from both URL and
localStorage sources.
@gggritso gggritso changed the title feat(utils): Add useQueryStateWithLocalStorage hook feat(utils): Add useQueryStateWithLocalStorage hook Jan 21, 2026
export function useQueryStateWithLocalStorage<T>(
queryKey: string,
localStorageKey: string,
parser: SingleParserBuilder<T & {}>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this limit it to non-arrays, as arrays need a MultiParserBuilder ?

Comment on lines +44 to +55
// If the parser has `.withDefault()`, it will _always_ return that default
// instead of `null` when there's no URL param. This breaks our priority order
// (URL > localStorage > default) because we can't distinguish between:
// 1. No URL param exists (should fall back to localStorage)
// 2. URL param explicitly set to the default value (should use that)
// Both would return the same value, making localStorage never take effect.
if ('defaultValue' in parser && defined(parser.defaultValue)) {
throw new Error(
`useQueryStateWithLocalStorage: parser should not have .withDefault() configured. ` +
`Pass the base parser and use the separate defaultValue parameter instead.`
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we get this check on type-level too, e.g. by making sure that T always contains | null ? I think if you provide a defaultValue for nuqs, null will be taken out of the union.

// The fallback state is read from `localStorage` directly. We are not using
// `useLocalStorageState` because we want to do the deserialization ourselves
// according to Nuqs configuration.
const stored = localStorageWrapper.getItem(localStorageKey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it concerning that we read from localStorage on every render? Would we not rather only read one time only, and then fill the url with that value?

@TkDodo

TkDodo commented Jan 21, 2026

Copy link
Copy Markdown
Collaborator

oh also: if we delete the value from the url (by setting it to null), the current implementation would fall back to the current localStorage value.

In general, I have been thinking out the problem space of “filling the url with values from localStorage” before, and I have a draft PR up on nuqs that could help:

…erformance

Address four review comments from TkDodo on PR #106625:

1. Array Support: Extended type signature to accept GenericParserBuilder,
   supporting both SingleParserBuilder (parseAsArrayOf) and MultiParserBuilder.
   Multi parsers store data as JSON arrays in localStorage.

2. Type-Level Safety: Updated type signature to use GenericParserBuilder<T>
   with runtime validation to prevent parsers with .withDefault() configured.

3. Performance: Refactored to read localStorage once on mount instead of
   every render. URL becomes the single source of truth after initialization,
   with effective value chain simplified to: urlValue ?? defaultValue.

4. Null Behavior: Setting setValue(null) now correctly clears both URL and
   localStorage, falling back to defaultValue instead of stale localStorage.

Added comprehensive test coverage for array support (both SingleParser and
MultiParser), null behavior, and initialization logic.

Co-Authored-By: Claude <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

}
setInitialized(true);
}
}, [initialized, urlValue, localStorageKey, parser, setUrlValue]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initialization flag not set when URL has initial value

Medium Severity

The initialized flag is only set to true inside the conditional block that requires !defined(urlValue). When the URL has a value on mount, the condition fails and setInitialized(true) never executes. This means initialized stays false for the component's lifetime. If the URL is later cleared by external means (browser back/forward, manual URL edit), the effect will incorrectly re-read from localStorage, violating the documented "read localStorage ONCE on mount" behavior.

Fix in Cursor Fix in Web

`useQueryStateWithLocalStorage: parser should not have .withDefault() configured. ` +
`Pass the base parser and use the separate defaultValue parameter instead.`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing defaultValue check for MultiParsers allows silent failures

Medium Severity

The .withDefault() check is explicitly skipped for MultiParsers (!isMultiParser(parser)), but MultiParsers with default values have the same problem as SingleParsers: they never return null, so the localStorage fallback never triggers. The test comment at line 403-404 confirms this by noting the custom parser "mimics parseAsNativeArrayOf but without the automatic default." If someone uses parseAsNativeArrayOf or a MultiParser with .withDefault(), the hook silently fails to read from localStorage instead of throwing a helpful error like it does for SingleParsers.

Fix in Cursor Fix in Web

@gggritso

Copy link
Copy Markdown
Member Author

@TkDodo great comments, thank you for taking a look! I set Claude loose on making the fixes, but even though it made a reasonable attempt I don't like the result.

I'm not feeling confident that wrapping Nuqs this way is robust. The code is looking pretty complicated and it's coupling super tight to Nuqs (e.g., using the eq method that way doesn't feel great). @JonasBa talked to me about this too, and had some reservations.

Do you think there's room here to contribute something more robust upstream to Nuqs? Jonas and I kicked a few ideas around.

One idea is to add another method on the parser builder like .withStorage that would proxy the writes to localStorage. That seems a little weird, since a parser shouldn't have side effects, but maybe viable

const [sort, setSort] = useQueryState('sort', parseAsString.withDefault('date').withStorage(localStora...

Another idea is to have some kind of option in the hook for a "backup store" that reads/writes go to:

const [sort, setSort] = useQueryState('sort', parseAsString.withDefault('date'), {backupStorage: localStorage}...

Another idea is to expose hooks for side effects:

const default = localStorage.get('...
const [sort, setSort] = useQueryState('sort', parseAsString.withDefault(default), {
  onWrite: (value) => { localStorage.set...
}...

What do you think? Would that be better? Or should we stick to wrapping useQueryState and using useEffect to sync it with localStorage?

@TkDodo

TkDodo commented Jan 22, 2026

Copy link
Copy Markdown
Collaborator

@gggritso my idea for reads is to make .withDefault() accept a functional initializer, similar to how useState works, so that we can read from localstorage there:

.withDefault(() => readFromLocalStorage())

this needs a bit of rework in nuqs (47ng/nuqs#1148), so maybe it’ll come in the next major.

I’m also thinking those default values should then, after being read, automatically be written to the url by dong a replace-navigation so that you can actually share the things you have in localstorage. I’m toying with that here: 47ng/nuqs#1159

As for writes, I always thought it would be fine to just have an effect set up that does the write. nuqs does have a global “middleware” on the adapter in processUrlSearchParams where I think we could technically do writes to localstorage too but a) it’s global and b) it gets URLSeachParams passed so I don’t think that’s the right place.

onWrite: (value) => { localStorage.set... could be interesting but lets see what @franky47 thinks about this

@franky47

Copy link
Copy Markdown

Hey y'all, thanks for the ping @TkDodo.

I like the onWrite: (value) => { localStorage.set... proposal, that sounds lightweight, declarative and composable (ie: can have different writers in different hooks on the same key for different purposes), which sounds good to me.

@gggritso

Copy link
Copy Markdown
Member Author

@franky47 @TkDodo 👀! Thanks for taking a look 🙏🏻

I really like the functional initializer idea. I had the same thought but because I couldn't figure out a way to make it work for setting the value I discarded the idea. It makes sense that writing doesn't have to go into the initializer, and I agree that useEffect would be fine there, and onWrite would be a nice-to-have

Is there anything I can do to support this work overall? I see that you're already thinking and working on it actively, but if you need any help, I'd be happy to contribute.

I'm going to close this PR and do a one-off solution in the product code where I need it, which I can replace with the nicer solution in the future 👍🏻

@gggritso gggritso closed this Jan 26, 2026
@TkDodo

TkDodo commented Jan 26, 2026

Copy link
Copy Markdown
Collaborator

@franky47 do you think we could implement default-value-as-a-function without the global store idea from 47ng/nuqs#1148 ?

It would mean that every instance of useQueryState() would make its own read from localStorage, which I think would be fine for us but I think you’d want to ship the global store a major, right?

@gggritso gggritso deleted the georgegritsouk/dain-1161-add-hook-for-state-driven-by-url-query-parameters-falling branch January 30, 2026 20:30
@github-actions github-actions Bot locked and limited conversation to collaborators Feb 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants