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.

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
npm install --save state-in-url
yarn add state-in-url
pnpm add state-in-url
In tsconfig.json in compilerOptions set "moduleResolution": "Bundler", or"moduleResolution": "Node16", or "moduleResolution": "NodeNext".
Possibly need to set "module": "ES2022", or "module": "ESNext"
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.
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>
)
}
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') },
},
];
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()} .../>
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 });
}
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>
);
}
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { state, updateUrl, updateState } = useUrlState<object>(someObj);
}
API is same as for Next.js version, except can pass options from NavigateOptions type.
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') },
},
];
Hooks to create your own useUrlState hooks with other routers, e.g. react-router or tanstack router.
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);
}
updateState for frequent updates and updateUrl to sync changes to urlSuspence to wrap client components in Next.jsFunction, BigInt or Symbol won't work, probably things like ArrayBuffer neither. Everything that can be serialized to JSON will work.next.js 14/15 with app router, no plans to support pages.See Contributing doc
Next.jsThis project is licensed under the MIT license.
No comments yet.
Sign in to be the first to comment.