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