feat(utils): Add useQueryStateWithLocalStorage hook#106625
Conversation
…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
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 ✓
…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.
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.
useQueryStateWithLocalStorage hook
| export function useQueryStateWithLocalStorage<T>( | ||
| queryKey: string, | ||
| localStorageKey: string, | ||
| parser: SingleParserBuilder<T & {}>, |
There was a problem hiding this comment.
does this limit it to non-arrays, as arrays need a MultiParserBuilder ?
| // 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.` | ||
| ); | ||
| } |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
|
oh also: if we delete the value from the url (by setting it to 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>
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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.
| `useQueryStateWithLocalStorage: parser should not have .withDefault() configured. ` + | ||
| `Pass the base parser and use the separate defaultValue parameter instead.` | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
|
@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 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 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 |
|
@gggritso my idea for reads is to make 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
|
|
Hey y'all, thanks for the ping @TkDodo. I like the |
|
@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 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 👍🏻 |
|
@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 |
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
Creates URL param (e.g.,
?sort=date) and localStorage key (e.g.,dashboards:sort).Use Cases
Gotchas
The only major gotcha is
defaultValue. Nuqs has a.withDefaultAPI, but we cannot allow using it here. If a parser has.withDefaultit'll never returnnulland we don't know when to fall back tolocalStorage. I figured that adefaultValueis a natural enough API in React land, and we have error checking around the gotcha, and an explanation.