Skip to content

Refine URL collection state: single useURLCollectionVariables hook + typed filter coercion#335

Merged
interacsean merged 2 commits into
improve-use-url-collection-statefrom
claude/keen-albattani-d5964b
Jun 26, 2026
Merged

Refine URL collection state: single useURLCollectionVariables hook + typed filter coercion#335
interacsean merged 2 commits into
improve-use-url-collection-statefrom
claude/keen-albattani-d5964b

Conversation

@interacsean

Copy link
Copy Markdown
Contributor

Proposed refinements on top of improve-use-url-collection-state (#293), in two parts: a hook-interface change and a bugfix to the URL-state code on that branch. Scoped to the library + the one consumer update the interface change forces — no unrelated demo/scaffolding.

1. Interface — collapse the common path into one hook

The base branch exposes URL persistence as a decorator returned by a hook, used like:

const withURLCollectionState = useURLCollectionState();
const { variables, control } = useCollectionVariables(
  withURLCollectionState({ tableMetadata, params: { pageSize: 20 } }),
);

useURLCollectionState() only ever wraps react-router's useSearchParams, so it provides no flexibility a single fused hook wouldn't — while forcing every call site through a two-hook dance and an awkward name collision (const withURLCollectionState = useURLCollectionState() shadows the exported withURLCollectionState).

This collapses the 99% path to one call:

const { variables, control } = useURLCollectionVariables({
  tableMetadata,
  params: { pageSize: 20 },
});

Public surface becomes three exports, each with a distinct job:

  • useCollectionVariables — router-free primitive (unchanged)
  • useURLCollectionVariables (new) — URL persistence in one call
  • withURLCollectionState(options, binding) — pure decorator, kept as the escape hatch for a non-react-router binding / composition

Removed useURLCollectionState (redundant with the fused hook; unreleased, so no external consumers).

2. Bugfix — typed filter values lost their type on URL round-trip

decodeFilterValue intentionally returns numeric/boolean values as strings (on its own it can't know the intended type). But parseCollectionSearchParams — the metadata-aware overload — never restored the type, so a deep-linked f.price:gt=130 came back as the string "130". That contradicts TypedCollectionVariables (which declares query.price.gt as number) and silently breaks any type-aware filtering — a real typed GraphQL Float/Int input would also reject a string.

Fix: when table metadata is present, coerce decoded filter values to the field's declared type (number/boolean), descending into in/nin arrays and between { min, max } bounds. The untyped overload (no metadata) is unchanged — values stay strings.

Tests

  • coerces number/boolean filter values (incl. in arrays) using metadata
  • round-trips a numeric gt filter through write → parse as a number
  • leaves numeric-looking values as strings when no metadata is provided
  • updated the hook test to the new useURLCollectionVariables shape

Verification

  • packages/core: lint clean, format clean, 27 tests pass, tsc clean.
  • nextjs example: tsc clean.
  • Verified live (vite example) against an f.price:gt=130 deep-link: rows hydrate correctly, filtered + sorted, no console errors.

Notes

  • examples/nextjs-app/.../data-table-demo.tsx is updated to the new hook — required by the export change, not optional.
  • The existing changeset is updated to document the single-hook API; no separate changeset for the bugfix since it's internal to the unreleased feature.

🤖 Generated with Claude Code

…typed filter values

Proposed refinements on top of improve-use-url-collection-state.

Interface: collapse the URL-state common path into one hook.
useURLCollectionState() only ever wrapped react-router's useSearchParams,
so it added no flexibility over a fused hook while forcing a two-hook dance.
Replace it with useURLCollectionVariables(options) (read + write URL state in
one call). Keep useCollectionVariables (router-free primitive) and the pure
withURLCollectionState(options, binding) decorator (custom-binding escape hatch).

Fix: parseCollectionSearchParams now coerces decoded filter values to the
field's declared metadata type (number/boolean, incl. in/nin arrays and between
bounds). Previously a URL-restored numeric filter such as f.price:gt=130 came
back as the string "130", contradicting TypedCollectionVariables and silently
breaking type-aware filtering. The untyped overload (no metadata) is unchanged.

Update the nextjs data-table-demo to the new hook, refresh the changeset, and
add tests for coercion and the numeric write -> parse round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@interacsean interacsean requested a review from a team as a code owner June 26, 2026 07:23
…iables

Runnable DataTable example with a mock remote query (filters, sort, cursor
pagination, multi-select) wired through useURLCollectionVariables, so state is
persisted to and hydrated from the URL. Doubles as the repro for the typed
filter-value coercion fix (e.g. ?f.price:gt=130 now hydrates correctly).

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

IzumiSy commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

I originally preferred the useCollectionVariables(withURLCollectionState(...)) shape because it kept useCollectionVariables as the main API and treated URL state as a small layer around it, rather than introducing a separate entry point. I was also a little concerned that having multiple hooks with the same general surface for different persistence targets could become confusing over time.

That said, after thinking about it more, I agree that useURLCollectionVariables(...) is the better default API for now.

The key point for me is that the important abstraction is still useCollectionVariables itself. As long as that remains the storage-agnostic primitive, adding a URL-specific convenience hook on top does not really reduce extensibility. withURLCollectionState(...) still gives us a way to compose URL state manually when needed.

I also don’t think keeping useURLCollectionState() as a separate hook gives us much extra future-proofing. Synchronous persistence targets can still be built on top of useCollectionVariables, and asynchronous persistence would require additional reset/controlled behavior there anyway.

So I’m 👍 on useURLCollectionVariables(...) as the default API.

@interacsean interacsean merged commit ff8c086 into improve-use-url-collection-state Jun 26, 2026
4 checks passed
@interacsean interacsean deleted the claude/keen-albattani-d5964b branch June 26, 2026 23:21
IzumiSy added a commit that referenced this pull request Jun 29, 2026
* improve: simplify and harden useUrlCollectionState

- Remove params from write effect deps using function updater to eliminate
  feedback loop (setParams → params change → effect re-runs)
- Fix filter value encoding: use JSON for arrays to correctly handle values
  containing commas (e.g. "Smith, John")
- Fix hydration race condition: introduce SyncPhase lifecycle (pending →
  hydrated → ready) to prevent writing stale defaults to URL before
  hydrated state propagates
- Simplify decodeFilterValue: remove startsWith check, rely on
  JSON.parse + Array.isArray for correctness
- Replace Array.from(next.keys()) with spread syntax

* fix: round-trip object-valued filters in useUrlCollectionState

decodeFilterValue only parsed JSON arrays back, so object-valued filters
(the `between` operator's { min, max } shape) round-tripped to a raw
string on reload and silently broke. Decode objects too; primitives like
"5"/"true" still fall through to strings, preserving string-vs-numeric
filter semantics.

Also harden the write-effect bail-out: compare on a sorted, JSON-encoded
snapshot (stableQueryString) rather than `.toString()`, which is
sensitive to filter key-insertion order and to `&`/`=` inside values.

Folds in fixes already running in the Denim Tears IMS copy of this hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): add withURLState helper for collection variables

* refactor(core): move collection URL state helpers to lib

* Refine URL collection state API

* Remove withURLState alias

* Add useURLCollectionState hook

* Make useURLCollectionState return a decorator

* Align collection state params callbacks

* Refine URL collection state: single `useURLCollectionVariables` hook + typed filter coercion (#335)

* refactor(collection): single useURLCollectionVariables hook + coerce typed filter values

Proposed refinements on top of improve-use-url-collection-state.

Interface: collapse the URL-state common path into one hook.
useURLCollectionState() only ever wrapped react-router's useSearchParams,
so it added no flexibility over a fused hook while forcing a two-hook dance.
Replace it with useURLCollectionVariables(options) (read + write URL state in
one call). Keep useCollectionVariables (router-free primitive) and the pure
withURLCollectionState(options, binding) decorator (custom-binding escape hatch).

Fix: parseCollectionSearchParams now coerces decoded filter values to the
field's declared metadata type (number/boolean, incl. in/nin arrays and between
bounds). Previously a URL-restored numeric filter such as f.price:gt=130 came
back as the string "130", contradicting TypedCollectionVariables and silently
breaking type-aware filtering. The untyped overload (no metadata) is unchanged.

Update the nextjs data-table-demo to the new hook, refresh the changeset, and
add tests for coercion and the numeric write -> parse round-trip.

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

* docs(example): add vite-app Products page demoing useURLCollectionVariables

Runnable DataTable example with a mock remote query (filters, sort, cursor
pagination, multi-select) wired through useURLCollectionVariables, so state is
persisted to and hydrated from the URL. Doubles as the repro for the typed
filter-value coercion fix (e.g. ?f.price:gt=130 now hydrates correctly).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: interacsean <seanhasselback@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants