Easily share complex state objects between unrelated React components, preserve types and structure, with TS validation. Without any hassle or boilerplate.
Will appreciate likes/stars, have proper tests and documentation.

Easily share complex state objects between unrelated React components, preserve types and structure, with TS validation. Without any hasssle or boilerplate.
Don't hesitate to open an issue if you found a bug, or for requesting features
Add a ⭐️ and follow me to support the project!
Will appreciate you feedback/opinion on discussions
Share if it useful for you. FB LinkedIn X.com VK
state-in-url
Simple state management with optional URL sync. Good for implementing deep links. Share complex states between unrelated React components, TS-friendly, NextJS compatible. Most of users don't care about URL, so, can use it to store your app state.
-
🧮 Store unsaved user forms in URL
-
🙃 Share the state between different components without changing url, good as alternative to signals and other state management tools
-
🧠 Sync data between unrelated client components
-
🔗 Shareable URLs with application state (Deep linking, URL state synchronization)
-
🔄 Easy state persistence across page reloads
-
🧩 Simple: No providers, reducers, boilerplate or new concepts, API similar to React.useState
-
📘 Typescript support and type Safety: Preserves data types and structure, good developer experience with IDE suggestions, strong typing and JSDoc comments
-
⚛️ Framework Flexibility: Separate hooks for Next.js and React.js applications, and functions for pure JS
-
⚙ Well tested: Unit tests and Playwright tests, high quality and support
-
⚡ Fast: Minimal rerenders, less than 1ms to encode and decode an object
-
🪶 Lightweight: Zero dependencies for a smaller footprint
Table of content
installation
1. Install package
npm install --save state-in-url
yarn add state-in-url
pnpm add state-in-url
2. Edit tsconfig.json
In tsconfig.json
in compilerOptions
set "moduleResolution": "Bundler"
, or"moduleResolution": "Node16"
, or "moduleResolution": "NodeNext"
.
Possibly need to set "module": "ES2022"
, or "module": "ESNext"
useUrlState
useUrlState
is a custom React hook for Next.js/React-Router applications that make communication between client components easy. It allows you to share any complex state and sync it with the URL search parameters, providing a way to persist state across page reloads and share application state via URLs.
useUrlState hook for Next.js
Usage examples
Basic
-
Define state shape
// userState.ts // State shape should be stored in a constant, don't pass an object directly export const userState: UserState = { name: '', age: 0 } type UserState = { name: string, age: number }
-
Import it and use
'use client'
import { useUrlState } from 'state-in-url/next';
import { userState } from './userState';
function MyComponent() {
// can pass `replace` arg, it's control will `updateUrl` will use `rounter.push` or `router.replace`, default replace=true
// const { state, updateState, updateUrl } = useUrlState({ defaultState: userState, searchParams, replace: false });
const { state, updateState, updateUrl } = useUrlState({ defaultState: userState });
// won't let you to accidently mutate state directly, requires TS
// state.name = 'John' // <- error
return (
<div>
<input value={state.name}
onChange={(ev) => { updateState({ name: ev.target.value }) }}
onBlur={() => updateUrl()}
/>
<input value={state.age}
onChange={(ev) => { updateState({ age: +ev.target.value }) }}
onBlur={() => updateUrl()}
/>
// same api as React.useState
<input value={state.name}
onChange={(ev) => { updateState(curr => ({ ...curr, name: ev.target.value })) }}
onBlur={() => updateUrl()}
/>
<button onClick={() => updateUrl(userState)}>
Reset
</button>
</div>
)
}
With complex state shape
export const form: Form = {
name: '',
age: undefined,
'agree to terms': false,
tags: [],
};
type Form = {
name: string;
age?: number;
'agree to terms': boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
'use client'
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
function TagsComponent() {
// `state` will infer from Form type!
const { state, updateUrl } = useUrlState({ defaultState: form });
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
updateUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[updateUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!state.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
Auto sync state
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
React.useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// will compare state by content not by reference and fire update only for new values
updateUrl(state);
}, 500);
return () => {
clearTimeout(timer.current);
};
}, [state, updateUrl]);
Syncing state onBlur
will be more aligned with real world usage.
<input onBlur={() => updateUrl()} .../>
With server side rendering
export default async function Home({ searchParams }: { searchParams: object }) {
return (
<Form searchParams={searchParams} />
)
}
// Form.tsx
'use client'
import React from 'react';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
const Form = ({ searchParams }: { searchParams: object }) => {
const { state, updateState, updateUrl } = useUrlState({ defaultState: form, searchParams });
}
Using hook in layout component
That a tricky part, since nextjs with app router doesn't allow to access searchParams from server side. There is workaround with using middleware, but it isn't pretty and can stop working after nextjs update.
// add to appropriate `layout.tsc`
export const runtime = 'edge';
// middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.url?.includes('_next') ? null : request.url;
const sp = url?.split?.('?')?.[1] || '';
const response = NextResponse.next();
if (url !== null) {
response.headers.set('searchParams', sp);
}
return response;
}
// Target layout component
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const sp = headers().get('searchParams') || '';
return (
<div>
<Comp1 searchParams={decodeState(sp, stateShape)} />
{children}
</div>
);
}
With arbitrary state shape (not recommended)
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { state, updateUrl, updateState } = useUrlState<object>(someObj);
}
useUrlState hook for React-Router
API is same as for Next.js version, except can pass options from NavigateOptions type.
Example
export const form: Form = {
name: '',
age: undefined,
'agree to terms': false,
tags: [],
};
type Form = {
name: string;
age?: number;
'agree to terms': boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
import { useUrlState } from 'state-in-url/react-router';
import { form } from './form';
function TagsComponent() {
const { state, updateUrl } = useUrlState({ defaultState: form });
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
updateUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[updateUrl],
);
return (
<div>
<Field text="Tags">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!state.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
useUrlStateBase hook for others routers
Hooks to create your own useUrlState
hooks with other routers, e.g. react-router or tanstack router.
useSharedState hook for React.js
Hook to share state between any React components, tested with Next.js and Vite.
'use client'
import { useSharedState } from 'state-in-url';
export const someState = { name: '' };
function SettingsComponent() {
const { state, setState } = useSharedState(someState);
}
useUrlEncode hook for React.js
encodeState and decodeState helpers
encode and decode helpers
- Define your state shape as a constant
- Use TypeScript for enhanced type safety and autocomplete
- Avoid storing sensitive information in URL parameters (SSN, API keys etc)
- Use
updateState
for frequent updates andupdateUrl
to sync changes to url - Use
Suspence
to wrap client components in Next.js - Use this extension for readable TS errors
Gothas
- Can pass only serializable values,
Function
,BigInt
orSymbol
won't work, probably things likeArrayBuffer
neither. Everything that can be serialized to JSON will work. - Vercel servers limit size of headers (query string and other stuff) to 14KB, so keep your URL state under ~5000 words. https://vercel.com/docs/errors/URL_TOO_LONG
- Tested with
next.js
14/15 with app router, no plans to support pages.
Contribute and/or run locally
See Contributing doc
Roadmap
- hook for
Next.js
- hook for 'react-router`
- hook for 'remix`
- hook for store state in hash ?
Contact & Support
- Create a GitHub issue for bug reports, feature requests, or questions
Changelog
License
This project is licensed under the MIT license.
Inspiration
No comments yet.
Sign in to be the first to comment.