feature-state-hook

star 416

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.

asmyshlyaev177 By asmyshlyaev177 schedule Updated 5/24/2026

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

// 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: '',
};
// 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 });
}
// 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.

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>
// 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.

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 }; // recreated every render
  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); // TS error on JSONCompatible<T>

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() }); // re-fires effect
}, [urlState, setUrl]);

Correct:

// 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:

// 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:

// 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:

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';  // no-op

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:

// Wanting ?tab=features to persist after a reset
const STATE = { tab: 'features' };
reset(); // URL becomes clean — no ?tab= at all

Correct:

// 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:

const JOBS_STATE     = { tab: 'details' };
const SETTINGS_STATE = { tab: 'profile' };
// both mounted on same page → fight over ?tab=

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.
Install via CLI
npx skills add https://github.com/asmyshlyaev177/state-in-url --skill feature-state-hook
Repository Details
star Stars 416
call_split Forks 10
navigation Branch main
article Path SKILL.md
More from Creator
asmyshlyaev177
asmyshlyaev177 Explore all skills →