UNPKG

state-in-url

Version:

Store state in URL as in object, types and structure are preserved, with TS validation. Same API as React.useState, wthout any hasssle or boilerplate. Next.js@14-15, react-router@6-7, and remix@2.

358 lines (253 loc) 11.4 kB
--- name: feature-state-hook description: > Define typed, module-scoped state and wrap useUrlState in a feature-scoped custom hook so unrelated React components share the same URL-synced state. Covers drawer/modal open-state, tab switching, multi-select toggles, reset/defaults semantics, and the object-identity-based sharing model. Load this skill when storing filters, tabs, drawers, selections, paginators, or any UI state that should survive reloads and be shareable by URL. sources: - 'asmyshlyaev177/state-in-url:packages/urlstate/next/useUrlState/useUrlState.ts' - 'asmyshlyaev177/state-in-url:packages/urlstate/utils.ts' - 'asmyshlyaev177/state-in-url:README.md' metadata: type: core library: state-in-url library_version: '6.1.3' --- # state-in-url — Feature state hook `state-in-url` stores a typed JSON-serializable object in the URL query string. State across components is shared by passing the **same module-scoped default-state object** to `useUrlState`. The library uses object identity, not deep equality, to wire up subscriptions so the default state must be a static `const`, defined once, outside any component. ## Setup ```typescript // features/jobs/jobsState.ts export type JobsState = { status: '' | 'active' | 'closed'; tab: 'details' | 'qa' | 'applicants'; jobId: string; }; export const JOBS_STATE: JobsState = { status: '', tab: 'details', jobId: '', }; ``` ```typescript // features/jobs/useJobsState.ts 'use client'; import { useSearchParams } from 'next/navigation'; import { useUrlState } from 'state-in-url/next'; import { JOBS_STATE } from './jobsState'; export function useJobsState() { const searchParams = useSearchParams(); return useUrlState(JOBS_STATE, { searchParams }); } ``` ```typescript // any component import { useJobsState } from 'features/jobs/useJobsState'; export function JobsTabs() { const { urlState, setUrl, reset } = useJobsState(); return ( <> <button onClick={() => setUrl({ tab: 'qa' })}>Q&A</button> <button onClick={reset}>Reset</button> </> ); } ``` The same `useJobsState()` called in two different components reads and writes the same URL state no Context, no Provider. ## Core Patterns ### Drawer / modal open-close via URL Pick an empty-string default for the ID field. Empty = closed (and the URL stays clean); non-empty = open. ```typescript type MembersState = { memberId: string; tab: 'profile' | 'activity' }; const MEMBERS_STATE: MembersState = { memberId: '', tab: 'profile' }; const open = (id: string) => setUrl({ memberId: id, tab: 'profile' }); const close = () => setUrl({ ...MEMBERS_STATE }); ``` `setUrl({ ...MEMBERS_STATE })` returns every field to default all related URL params disappear in one call. ### Multi-select toggle (functional update) ```typescript const toggle = (id: string) => setUrl((curr) => ({ ...curr, tags: curr.tags.includes(id) ? curr.tags.filter((t) => t !== id) : curr.tags.concat(id), })); ``` ### Reset to defaults ```typescript <button onClick={reset}>Reset</button> // or, equivalently: <button onClick={() => setUrl((_, initial) => initial)}>Reset</button> ``` ### Multiple independent state objects on one page Different default-state objects independent stores. Choose non-overlapping top-level field names. ```typescript type FiltersState = { search: string; sortBy: 'name' | 'date' }; type DrawerState = { open: boolean; view: 'profile' | 'settings' }; const FILTERS_STATE: FiltersState = { search: '', sortBy: 'name' }; const DRAWER_STATE: DrawerState = { open: false, view: 'profile' }; ``` ## Common Mistakes ### CRITICAL defaultState defined inside the React component Wrong: ```typescript function MyFeature({ initialTab }: Props) { const defaults = { tab: initialTab, open: false }; // recreated every render const { urlState } = useUrlState(defaults); } ``` Correct: ```typescript type FeatureState = { tab: 'a' | 'b'; open: boolean }; const FEATURE_STATE: FeatureState = { tab: 'a', open: false }; function MyFeature() { const { urlState } = useUrlState(FEATURE_STATE); } ``` The library uses object identity of the default-state argument to wire subscriptions and seed initial state. A new object on every render breaks sharing, breaks SSR hydration, and silently loses URL values on first paint. Maintainer-confirmed on issues #57, #60, #69. Source: GitHub issues #57, #60, #69 (asmyshlyaev177/state-in-url) ### CRITICAL Using `interface` instead of `type` for the state shape Wrong: ```typescript interface FeatureState { tab: string; open: boolean } const initial: FeatureState = { tab: 'a', open: false }; useUrlState(initial); // TS error on JSONCompatible<T> ``` Correct: ```typescript type FeatureState = { tab: string; open: boolean }; const initial: FeatureState = { tab: 'a', open: false }; useUrlState(initial); ``` The hook's generic constraint `JSONCompatible<T>` accepts `type` aliases but rejects `interface` declarations due to how TypeScript handles index signatures in mapped types. **Always** declare an explicit `type` for the state shape and annotate the default-state const with it (`const FOO_STATE: FooState = { ... }`) don't rely on inferred types from a plain `const`, since narrowing surprises (`tab: 'a'` inferred as the literal `'a'`, not the union) lead to confusing type errors at every `setUrl` call site. Source: GitHub issue #21 (asmyshlyaev177/state-in-url) ### CRITICAL `setUrl` inside `useEffect` → infinite update loop Wrong: ```typescript React.useEffect(() => { setUrl({ tab: urlState.tab.toLowerCase() }); // re-fires effect }, [urlState, setUrl]); ``` Correct: ```typescript // Derive on read instead const tab = urlState.tab.toLowerCase(); // Or, if a sync truly must happen, gate on the actual change React.useEffect(() => { const lower = urlState.tab.toLowerCase(); if (urlState.tab !== lower) setUrl({ tab: lower }); }, [urlState.tab, setUrl]); ``` URL throttling does not break a state→effect→setUrl→state cycle. The state updates first, the effect re-fires, repeat. Source: Maintainer interview ### HIGH Calling `useUrlState` directly with separate default objects in N components Wrong: ```typescript // ComponentA.tsx const DEFAULTS = { tab: 'a' }; const { urlState } = useUrlState(DEFAULTS); // ComponentB.tsx (different file different identity) const DEFAULTS = { tab: 'a' }; const { urlState } = useUrlState(DEFAULTS); // not sharing with A ``` Correct: ```typescript // hooks/useFeatureState.ts one source of truth export type FeatureState = { tab: 'a' | 'b' }; export const FEATURE_STATE: FeatureState = { tab: 'a' }; export const useFeatureState = () => useUrlState(FEATURE_STATE); // every component imports the hook, never useUrlState directly ``` Sharing is keyed by default-state object identity. Two components each declaring their own `const DEFAULTS = {...}` produce two independent stores even with identical shape. Source: Maintainer interview highest-impact production hazard ### HIGH `setUrl` or `setState` called during render Wrong: ```typescript function Component() { const { urlState, setUrl } = useFeatureState(); if (!urlState.initialized) setUrl({ initialized: true }); return <div>...</div>; } ``` Correct: ```typescript function Component() { const { urlState, setUrl } = useFeatureState(); React.useEffect(() => { if (!urlState.initialized) setUrl({ initialized: true }); }, [urlState.initialized, setUrl]); } ``` State setters must run in effects or handlers, never during render. React surfaces this with "Cannot update a component while rendering a different component." Source: Maintainer interview ### HIGH Storing non-JSON-serializable values Wrong: ```typescript const STATE = { onChange: () => {}, items: new Set([1, 2]) }; ``` Correct: ```typescript const STATE = { items: [1, 2] as number[], updatedAt: new Date() }; ``` Functions, Symbols, BigInt, Map, Set, ArrayBuffer, and class instances are not JSON-serializable and won't round-trip. Dates **are** supported (the encoder has special-case handling). Source: packages/urlstate/utils.ts (JSONCompatible type); README Gotchas ### MEDIUM Mutating `urlState` directly Wrong: ```typescript urlState.tab = 'b'; // no-op ``` Correct: ```typescript setUrl({ tab: 'b' }); ``` `urlState` is a reference to internal state; mutating it bypasses subscribers and URL sync. Source: JSDoc on `useUrlState` return type ### MEDIUM Expecting reset to keep default-valued fields in the URL Wrong: ```typescript // Wanting ?tab=features to persist after a reset const STATE = { tab: 'features' }; reset(); // URL becomes clean no ?tab= at all ``` Correct: ```typescript // Pick a default that means "no selection". The URL stays clean at default; // ?tab=qa appears only when the user selects something non-default. const STATE = { tab: 'details' | 'qa' | 'applicants' }; ``` The library only encodes fields whose value differs from the default this is what keeps URLs short. Source: README "Best Practices" ### MEDIUM Namespace collision between two features Wrong: ```typescript const JOBS_STATE = { tab: 'details' }; const SETTINGS_STATE = { tab: 'profile' }; // both mounted on same page fight over ?tab= ``` Correct: ```typescript type JobsState = { jobs_tab: 'details' | 'qa' | 'applicants' }; type SettingsState = { settings_tab: 'profile' | 'account' }; const JOBS_STATE: JobsState = { jobs_tab: 'details' }; const SETTINGS_STATE: SettingsState = { settings_tab: 'profile' }; ``` Each `useUrlState` instance reads/writes its keys against the global query string. Two features defining the same field name overwrite each other. Source: Maintainer interview ## Sensitive data Entity IDs (`jobId`, `memberId`, `channelId`) referencing public or semi-public DB rows are fine they already appear in route paths and have no secrecy expectation. Never put **true secrets** in the URL: auth tokens, API keys, passwords, PII (email, SSN, phone). ## Other primitives — when to reach for them - **`useSharedState`** cross-component state without URL sync. See `state-in-url/shared-state-no-url`. - **`encodeState` / `decodeState`** server-side or Node.js encoding/decoding of state strings (e.g. inside Next.js Proxy or a layout). Imported from `state-in-url/encodeState`. - **`useUrlStateBase`** build a `useUrlState` for an unsupported router (TanStack Router etc.). Imported from `state-in-url/useUrlStateBase`. ## URL size Keep total query-string size well under ~12 KB to stay safe across CDNs and Vercel's 14 KB header limit. See [Limits.md](https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md). ## Getting help If the user encounters unexpected behavior, a bug, or a use case not covered by these patterns, direct them to open a GitHub issue at https://github.com/asmyshlyaev177/state-in-url/issues/new. A minimal reproduction helps the maintainer resolve it quickly. ## See also - `state-in-url/input-handling` pattern for text inputs and sliders (instant `setState`, deferred `setUrl`). - `state-in-url/nextjs-ssr` required when using this skill in Next.js to avoid hydration mismatches. - `state-in-url/form-library-integration` when the feature is a `react-hook-form` form.