| 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.
|
| type | core |
| library | state-in-url |
| library_version | 6.1.3 |
| 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"] |
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
export type JobsState = {
status: '' | 'active' | 'closed';
tab: 'details' | 'qa' | 'applicants';
jobId: string;
};
export const JOBS_STATE: JobsState = {
status: '',
tab: 'details',
jobId: '',
};
'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 });
}
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.
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)
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
<button onClick={reset}>Reset</button>
<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.
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:
function MyFeature({ initialTab }: Props) {
const defaults = { tab: initialTab, open: false };
const { urlState } = useUrlState(defaults);
}
Correct:
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:
interface FeatureState { tab: string; open: boolean }
const initial: FeatureState = { tab: 'a', open: false };
useUrlState(initial);
Correct:
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:
React.useEffect(() => {
setUrl({ tab: urlState.tab.toLowerCase() });
}, [urlState, setUrl]);
Correct:
const tab = urlState.tab.toLowerCase();
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:
const DEFAULTS = { tab: 'a' };
const { urlState } = useUrlState(DEFAULTS);
const DEFAULTS = { tab: 'a' };
const { urlState } = useUrlState(DEFAULTS);
Correct:
export type FeatureState = { tab: 'a' | 'b' };
export const FEATURE_STATE: FeatureState = { tab: 'a' };
export const useFeatureState = () => useUrlState(FEATURE_STATE);
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:
function Component() {
const { urlState, setUrl } = useFeatureState();
if (!urlState.initialized) setUrl({ initialized: true });
return <div>...</div>;
}
Correct:
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:
const STATE = { onChange: () => {}, items: new Set([1, 2]) };
Correct:
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:
urlState.tab = 'b';
Correct:
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:
const STATE = { tab: 'features' };
reset();
Correct:
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:
const JOBS_STATE = { tab: 'details' };
const SETTINGS_STATE = { tab: 'profile' };
Correct:
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.
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.