Skip to content

Conversation

@gggritso
Copy link
Member

@gggritso gggritso commented Jan 20, 2026

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

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

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

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

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

…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
Copy link

linear bot commented Jan 20, 2026

@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 ✓
Copy link
Contributor

@cursor cursor bot left a comment

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.

const setValue = (value: T) => {
setUrlValue(value);
setLocalStorageValue(value);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Unstable setValue function causes infinite loops in consumers

High Severity

The setValue function is defined inline without useCallback, creating a new function reference on every render. This breaks React's referential equality expectations for setter functions. Consumers using setValue in dependency arrays (e.g., useEffect, useCallback, useMemo) will trigger on every render, potentially causing infinite loops or excessive re-renders.

Fix in Cursor Fix in Web

if (urlValue !== null && urlValue !== undefined && urlValue !== localStorageValue) {
setLocalStorageValue(urlValue);
}
}, [urlValue, localStorageValue, setLocalStorageValue]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Reference equality check fails for arrays and objects

Medium Severity

The comparison urlValue !== localStorageValue uses strict reference equality, which fails for arrays and objects. When using parseAsArrayOf (documented as supported), two arrays with identical contents but different references will always compare as unequal. This causes unnecessary localStorage writes on every render where URL and localStorage have matching array values, and could cause infinite re-render loops if the URL parser returns new array instances on each render.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants