A React hook for managing state synchronized with URL hash parameters. Useful for allowing specified pieces of state to be copy-able for sending in links or for state changes to be navigable via forward and back in the browser. Perfect for creating shareable URLs with filters, active modals, and other stateful UI components.
- You do not want the side effects that come with using url query params.
- You are already using state and need to quickly make part of state shareable via url without a huge refactor.
- Want to add stateful url fetaures and need to ensure safety with other url parameters that could be set in other parts of your application.
- You are already using state and realize that you should have used the url to store state in the first place. (We provide util functions that will help while you gracefully migrate from using state to using the url).
Sidebar
I would love to expand this to more frameworks / communities in the future. I know many frameworks do not have the same "UI is a function of state" philosophy as React, but I could see the intended function for this project being useful in a mutated form appropriate for each framework. (Where are my other Solid.js interested folks)- π Automatic URL hash synchronization
- π― Type-safe with full TypeScript support
- π SSR/SSG compatible (Next.js ready)
- π¨ Customizable serialization strategies
- β‘ Debounced updates to prevent URL spam
- π Browser navigation support (back/forward buttons) - Not default behavior. Must be specified in options.
- π Utility hooks for common patterns
- π¦ Zero dependencies (except React)
- β¨ Automatic memoization - define serializers inline without performance issues!
npm install use-stateful-url
# or
bun add use-stateful-url
#or
yarn add use-stateful-url
# or
pnpm add use-stateful
import { useStatefulUrl } from "use-stateful-url";
function MyComponent() {
const { state, setState, isInitialized } = useStatefulUrl({
/* URL will look something like: example.com/gallery#filters=tag1,tag2&page=2 */
/* (Actually, the url will contain special delimiters. More on that later.) */
filters: new Set<string>(),
page: 1,
});
if (!isInitialized) {
return <div>Loading...</div>;
}
return (
<div>
<button
onClick={(e) =>
setState((prev) => {
const updatedPage = prev.page - 1;
return { ...prev, page: updatedPage };
})
}
>
Previous Page
</button>
<span>Current page: {state.page}</span>
<button
onClick={(e) =>
setState((prev) => {
const updatedPage = prev.page + 1;
return { ...prev, page: updatedPage };
})
}
>
Next Page
</button>
</div>
);
}
You can now define serializers inline without worrying about performance! The hook automatically memoizes them for you:
const { state, setState } = useStatefulUrl(
{
tags: new Set<string>(),
selectedId: null,
},
{
// β
This is now perfectly fine! No infinite re-renders!
serializers: {
serialize: (state) => ({
tags: state.tags.size > 0 ? Array.from(state.tags).join(",") : "",
item: state.selectedId || "",
}),
deserialize: (params) => ({
tags: new Set(params.get("tags")?.split(",") || []),
selectedId: params.get("item") || null,
}),
},
}
);
Main hook for managing hash state.
initialState: T
- Initial state objectoptions?: StatefulUrlHashOptions<T>
- Configuration options
{
state: T; // Current state
setState: (newState) => void; // Update state function
isInitialized: boolean; // Whether initialized from URL
syncToUrl: () => void; // Manually sync state to URL
clearHash: () => void; // Clear hash and reset state
getHashWithoutState: () => string; // Get hash without useStatefulUrl content
getStateFromHash: () => string; // Get only useStatefulUrl content
}
interface StatefulUrlHashOptions<T> {
debounceMs?: number; // Debounce delay (default: 100ms)
usePushState?: boolean; // Use pushState vs replaceState
serializers?: StatefulUrlHashSerializers<T>; // Custom serialization
initializeOnMount?: boolean; // Initialize from URL (default: true)
delimiters?: {
// Delimiters to isolate useStatefulUrl content
start?: string; // Default: "__UHS-"
end?: string; // Default: "-UHS__"
};
positionStrategy?: "preserve" | "end" | "start"; // Where to place content (default: 'end')
}
For managing string arrays:
const { value, setValue, isInitialized } = useStatefulUrlArray(
"tags",
[],
["react", "typescript", "javascript"] // optional validation
);
// URL: #tags=react,typescript
For managing Sets:
const { value, setValue, isInitialized } = useStatefulUrlSet(
"categories",
new Set(),
["tech", "design", "business"]
);
// URL: #categories=tech,design
For managing single strings:
const { value, setValue, isInitialized } = useStatefulUrlString("search", "");
// URL: #search=hello%20world
useStatefulUrl automatically isolates its content using delimiters, so it won't interfere with existing hash parameters in your app!
The package wraps its state content between special delimiters:
# Your existing hash params remain untouched!
https://yourapp.com/page#existing=value&more=params__UHS-search=hello&filters=react,vue-UHS__other=stuff
By default, useStatefulUrl uses __UHS-
and -UHS__
delimiters:
const { state, setState } = useStatefulUrl({
search: "",
filters: new Set(),
});
// URL becomes: #existing=value__UHS-search=hello&filters=react,vue-UHS__more=stuff
// Your existing hash content is completely preserved!
You can customize the delimiters to match your preferences:
const { state, setState } = useStatefulUrl(
{ search: "", page: 1 },
{
delimiters: {
start: "<<MYAPP>>",
end: "<</MYAPP>>",
},
}
);
// URL: #existing=value<<MYAPP>>search=hello&page=2<</MYAPP>>more=stuff
Control where useStatefulUrl content appears in the hash:
// Default: 'end' - always places useStatefulUrl content at the end (performance optimized)
const { state } = useStatefulUrl(initialState); // Uses 'end' by default
// 'preserve' - keeps original position, appends to end if first time
const { state } = useStatefulUrl(initialState, {
positionStrategy: "preserve", // For maintaining existing URL structure
});
// 'start' - always places useStatefulUrl content at the beginning
const { state } = useStatefulUrl(initialState, {
positionStrategy: "start", // For priority visibility
});
Why 'end' is the default: This prevents "thrashing" string work where external hash updates cause useStatefulUrl content to move around in the URL, leading to better performance and more predictable behavior. No reconstructing strings from within on every state update if hash state is always at the end (fewer string operations).
Access different parts of the hash easily:
const { getHashWithoutState, getStateFromHash } = useStatefulUrl({
search: "",
filters: [],
});
// If URL is: #analytics=enabled__UHS-search=react&filters=js,ts-UHS__debug=true
console.log(getHashWithoutState()); // "analytics=enabled&debug=true"
console.log(getStateFromHash()); // "search=react&filters=js,ts"
For advanced use cases, import the global utilities:
import { hashUtils } from "use-hash-state";
// Check if hash contains useStatefulUrl content
const hasState = hashUtils.hasHashState();
// Get hash parts with custom delimiters
const hashWithoutState = hashUtils.getHashWithoutState({
start: "<<START>>",
end: "<<END>>",
});
// Safely update the non-useStatefulUrl portion of the hash
hashUtils.setExternalHash("tab=profile&debug=true");
// This preserves useStatefulUrl content while updating external parameters
Need to update your non-useStatefulUrl parameters? Use the utility function:
import { hashUtils } from "use-hash-state";
// Your existing code that updates hash
function changeTab(newTab: string) {
// OLD WAY (unsafe - overwrites useStatefulUrl content):
// window.location.hash = `tab=${newTab}&debug=true`;
// NEW WAY (safe - preserves useStatefulUrl content):
hashUtils.setExternalHash(`tab=${newTab}&debug=true`);
}
// useStatefulUrl content is automatically preserved!
Perfect for gradual migration! You can introduce useStatefulUrl without breaking existing functionality:
// Before: Your app uses #tab=profile§ion=settings
// After: Add useStatefulUrl alongside existing usage
const { state } = useStatefulUrl({
searchQuery: "",
selectedItems: new Set(),
});
// URL becomes: #tab=profile§ion=settings__UHS-searchQuery=hello&selectedItems=item1,item2-UHS__
// Your existing hash reading logic continues to work:
const currentTab = new URLSearchParams(window.location.hash.substring(1)).get(
"tab"
); // Still works!
// Update existing params safely:
hashUtils.setExternalHash(`tab=settings§ion=profile`); // useStatefulUrl content preserved
Thanks to automatic memoization, you can define complex serializers inline:
const { state, setState } = useStatefulUrl(
{
complexData: { nested: { value: "test" } },
filters: new Set(["tag1", "tag2"]),
currentPage: 1,
},
{
serializers: {
serialize: (state) => ({
// Complex logic can be defined inline safely
data: JSON.stringify(state.complexData),
filters:
state.filters.size > 0 ? Array.from(state.filters).join(",") : "",
page: state.currentPage.toString(),
}),
deserialize: (params) => ({
complexData: (() => {
try {
return JSON.parse(params.get("data") || "{}");
} catch {
return { nested: { value: "test" } };
}
})(),
filters: new Set(
params.get("filters")?.split(",").filter(Boolean) || []
),
currentPage: parseInt(params.get("page") || "1", 10),
}),
},
}
);
import { useStatefulUrl } from "use-hash-state";
function PortfolioGallery({ projects }) {
const { state, setState, isInitialized } = useStatefulUrl(
{
selectedTags: new Set<string>(),
selectedItemId: null as string | null,
},
{
// Inline serializers work perfectly!
serializers: {
serialize: (state) => {
const result = {};
if (state.selectedTags.size > 0) {
result.tags = Array.from(state.selectedTags).join(",");
}
if (state.selectedItemId) {
result.item = state.selectedItemId;
}
return result;
},
deserialize: (params) => ({
selectedTags: new Set(params.get("tags")?.split(",") || []),
selectedItemId: params.get("item") || null,
}),
},
}
);
const toggleTag = (tag: string) => {
setState((prev) => {
const newTags = new Set(prev.selectedTags);
if (newTags.has(tag)) {
newTags.delete(tag);
} else {
newTags.add(tag);
}
return { ...prev, selectedTags: newTags };
});
};
const filteredProjects =
state.selectedTags.size === 0
? projects
: projects.filter((p) =>
p.tags.some((tag) => state.selectedTags.has(tag))
);
if (!isInitialized) return <div>Loading...</div>;
return (
<div>
{/* Filter UI */}
{["react", "vue", "angular"].map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={state.selectedTags.has(tag) ? "active" : ""}
>
{tag}
</button>
))}
{/* Projects */}
{filteredProjects.map((project) => (
<div
key={project.id}
onClick={() =>
setState((prev) => ({ ...prev, selectedItemId: project.id }))
}
>
{project.title}
</div>
))}
{/* Modal */}
{state.selectedItemId && (
<Modal
onClose={() =>
setState((prev) => ({ ...prev, selectedItemId: null }))
}
>
{/* Modal content */}
</Modal>
)}
</div>
);
}
The package includes pre-built serializers for common data types:
import { hashSerializers } from "use-hash-state";
// String arrays
hashSerializers.stringArray.serialize(["a", "b", "c"]); // "a,b,c"
hashSerializers.stringArray.deserialize("a,b,c"); // ['a', 'b', 'c']
// Sets
hashSerializers.stringSet.serialize(new Set(["x", "y"])); // "x,y"
hashSerializers.stringSet.deserialize("x,y"); // Set(['x', 'y'])
// Booleans
hashSerializers.boolean.serialize(true); // "true"
hashSerializers.boolean.deserialize("true"); // true
// Numbers
hashSerializers.number.serialize(42); // "42"
hashSerializers.number.deserialize("42"); // 42
// JSON objects
hashSerializers.json.serialize({ a: 1 }); // '{"a":1}'
hashSerializers.json.deserialize('{"a":1}'); // {a: 1}
URL hash parameters work like regular query parameters but come after the #
symbol:
- Regular URL:
https://example.com/page?param=value&other=123
- Hash parameters:
https://example.com/page#param=value&other=123
The key difference is that hash parameters don't trigger server requests and are perfect for client-side state.
The useStatefulUrl
package uses the browser's built-in URLSearchParams
API for all URL encoding and decoding operations. This means:
Values containing special characters are automatically handled:
// If your state contains: { search: "cats & dogs", category: "Q&A" }
// The URL becomes: #search=cats%20%26%20dogs&category=Q%26A
// When parsed back: { search: "cats & dogs", category: "Q&A" }
Common characters that get encoded:
&
becomes%26
=
becomes%3D
+
becomes%2B
- Space becomes
%20
#
becomes%23
Your custom serializers should return plain string values - encoding is handled automatically:
// β
CORRECT - Return plain strings, encoding handled automatically
serializers: {
serialize: (state) => ({
search: state.searchTerm, // "cats & dogs" β automatically encoded
tags: state.tags.join(","), // ["React", "Q&A"] β "React,Q%26A"
data: JSON.stringify(state.object), // Complex object β JSON string β encoded
}),
deserialize: (params) => ({
searchTerm: params.get("search") || "", // Automatically decoded
tags: params.get("tags")?.split(",") || [], // Automatically decoded then split
object: JSON.parse(params.get("data") || "{}"), // Decoded then parsed
}),
}
Never manually construct URL parameter strings in your serializers:
// β WRONG - This breaks URL parsing!
serializers: {
serialize: (state) => ({
// This creates malformed URLs if values contain & or =
combined: `search=${state.search}&type=${state.type}`,
}),
}
// β WRONG - Manual encoding is unnecessary and error-prone
serializers: {
serialize: (state) => ({
search: encodeURIComponent(state.search), // URLSearchParams does this!
}),
}
-
Comma-separated values: If your values might contain commas, consider JSON serialization:
// If tags can contain commas: ["React, Vue", "Next.js"] serialize: (state) => ({ tags: JSON.stringify(Array.from(state.tags)), // Safer than join(",") }), deserialize: (params) => ({ tags: new Set(JSON.parse(params.get("tags") || "[]")), }),
-
Empty vs undefined: URLSearchParams treats missing parameters as
null
:deserialize: (params) => ({ search: params.get("search") || "", // params.get() returns null if missing }),
-
Array splitting edge cases: Handle empty strings carefully:
deserialize: (params) => ({ tags: params.get("tags")?.split(",").filter(Boolean) || [], // Remove empty strings }),
Check above for common pitfalls with url serialization
The Problem: When state is loaded from a URL hash on mount, it loads ALL values at once. This is different from user interaction patterns where state typically builds up incrementally, potentially causing unexpected behavior.
// β PROBLEMATIC: Component expects incremental state changes
function SearchFilters() {
const { state, setState } = useStatefulUrl({
selectedFilters: [] as string[],
searchQuery: "",
});
// This effect expects filters to be added one at a time
useEffect(() => {
if (state.selectedFilters.length > 0) {
// π¨ This fires once with ALL filters when loaded from URL
// but fires multiple times when user adds filters individually
trackFilterAdded(state.selectedFilters[state.selectedFilters.length - 1]);
}
}, [state.selectedFilters]);
// Animation that expects step-by-step changes
useEffect(() => {
state.selectedFilters.forEach((filter, index) => {
// π¨ All animations fire simultaneously when loaded from URL
setTimeout(() => animateFilterIn(filter), index * 100);
});
}, [state.selectedFilters]);
}
β Solution Pattern: Differentiate between mount initialization and user interaction:
function SearchFilters() {
const { state, setState, isInitialized } = useStatefulUrl({
selectedFilters: [] as string[],
searchQuery: "",
});
const [hasUserInteracted, setHasUserInteracted] = useState(false);
const prevFiltersRef = useRef<string[]>([]);
// Track when user actually interacts vs URL initialization
const addFilter = useCallback(
(filter: string) => {
setHasUserInteracted(true);
setState((prev) => ({
...prev,
selectedFilters: [...prev.selectedFilters, filter],
}));
},
[setState]
);
// Handle analytics differently for URL load vs user interaction
useEffect(() => {
if (!isInitialized) return; // Wait for URL state to load
const newFilters = state.selectedFilters.filter(
(filter) => !prevFiltersRef.current.includes(filter)
);
if (hasUserInteracted && newFilters.length > 0) {
// Only track for actual user interactions, not URL loads
newFilters.forEach((filter) => trackFilterAdded(filter));
}
prevFiltersRef.current = state.selectedFilters;
}, [state.selectedFilters, isInitialized, hasUserInteracted]);
// Handle animations based on context
useEffect(() => {
if (!isInitialized) return;
if (hasUserInteracted) {
// Animate only new filters for user interaction
const newFilters = state.selectedFilters.filter(
(filter) => !prevFiltersRef.current.includes(filter)
);
newFilters.forEach((filter) => animateFilterIn(filter));
} else {
// For URL loads, animate all at once or skip animation
animateAllFiltersIn(state.selectedFilters);
}
}, [state.selectedFilters, isInitialized, hasUserInteracted]);
}
The Problem: Large state objects can create extremely long URLs that browsers might truncate.
// β PROBLEMATIC: Can create massive URLs
const { state } = useStatefulUrl({
userProfiles: [], // Array of 100+ user objects
searchHistory: [], // Large array of search terms
detailedSettings: {}, // Complex nested object
});
β Solution: Be selective about what gets synchronized:
// β
BETTER: Only sync essential, shareable state
const { state: urlState, setState: setUrlState } = useStatefulUrl({
selectedUserId: null,
searchQuery: "",
activeTab: "users",
});
// Keep non-shareable state local
const [userProfiles, setUserProfiles] = useState([]);
const [searchHistory, setSearchHistory] = useState([]);
The Problem: Rapid state changes create too many browser history entries.
// β PROBLEMATIC: Every keystroke creates history entry
function SearchInput() {
const { state, setState } = useStatefulUrl({ query: "" });
return (
<input
value={state.query}
onChange={(e) => setState({ query: e.target.value })} // π¨ History entry per keystroke
/>
);
}
β Solution: Use debouncing and consider when to use push vs replace: Additionally, consider adding search terms, or other similar types of state that accumulate input, to hash state only after a submit like event.
OR
add a second hash state with a much longer debounce window and ITS OWN UNIQUE DELIMITERS
For the first option: It adds more work to you as a developer to ensure proper sync and usage of "submitted" state with "active input" state, but would result in UX more accurate to a user's mental model. When a user clicks "back" in the browser, it will remove the entire search, not just one or two characters from the search depending on how debounce is handled. This is more likely the expected behavior of a user
For the second option: This could be a better option when you are "optimistically" handling search as a user types. Once a user has stopped typing for a given amount of time (say 1.5 seconds), it is probably safe to assume that they intended for what had been typed to be considered a "search submission."
Improvement for this type of "debounced" handling is being considered for future releases.
// β
SOLUTION: Debounce and selective history strategy
function SearchInput() {
const { state, setState } = useStatefulUrl(
{ query: "" },
{
debounceMs: 1500, // Debounce URL updates
usePushState: false, // Use replaceState for transient changes
}
);
const [localQuery, setLocalQuery] = useState(state.query);
// Update local state immediately for responsive UI
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalQuery(e.target.value);
setState({ query: e.target.value }); // Debounced URL update
};
return <input value={localQuery} onChange={handleChange} />;
}
The Problem: Server-rendered content doesn't match client state from URL hash.
// β PROBLEMATIC: Hydration mismatch
function FilteredList() {
const { state } = useStatefulUrl({ showExpanded: false });
// π¨ Server renders false, client might have true from URL
return (
<div>{state.showExpanded ? <ExpandedContent /> : <CollapsedContent />}</div>
);
}
β Solution: Always check initialization status:
// β
SOLUTION: Prevent hydration mismatches
function FilteredList() {
const { state, isInitialized } = useStatefulUrl({ showExpanded: false });
// Don't render state-dependent content until initialized
if (!isInitialized) {
return <CollapsedContent />; // Always match server render
}
return (
<div>{state.showExpanded ? <ExpandedContent /> : <CollapsedContent />}</div>
);
}
The Problem: State updates and URL updates happening out of sync with async operations.
// β PROBLEMATIC: Race condition potential
function DataLoader() {
const { state, setState } = useStatefulUrl({ userId: null, userData: null });
useEffect(() => {
if (state.userId) {
fetchUser(state.userId).then((userData) => {
// π¨ What if userId changed while fetching?
setState((prev) => ({ ...prev, userData }));
});
}
}, [state.userId]);
}
β Solution: Use cleanup and current state checks:
// β
SOLUTION: Handle race conditions properly
function DataLoader() {
const { state, setState } = useStatefulUrl({ userId: null, userData: null });
useEffect(() => {
if (!state.userId) return;
let cancelled = false;
fetchUser(state.userId).then((userData) => {
if (!cancelled) {
setState((prev) => {
// Double-check state hasn't changed
if (prev.userId === state.userId) {
return { ...prev, userData };
}
return prev;
});
}
});
return () => {
cancelled = true;
};
}, [state.userId]);
}
The Problem: Using multiple useStatefulUrl
hooks on the same page without unique delimiters could cause unpredictable behavior. Be aware of parents and their children consuming useStatefulUrl
// β PROBLEMATIC: Multiple hooks with default delimiters will conflict
function ParentComponent() {
const { state: userFilters } = useStatefulUrl({
selectedUsers: new Set<string>(),
userPage: 1,
});
// Uses default delimiters: __UHS- and -UHS__
return (
<div>
<UserList filters={userFilters} />
<ProductSearch /> {/* This component also uses useStatefulUrl! */}
</div>
);
}
function ProductSearch() {
const { state: searchState } = useStatefulUrl({
query: "",
category: "all",
});
// π¨ CONFLICT: Also uses __UHS- and -UHS__ delimiters!
// Both hooks will overwrite each other's URL content
}
The Result: Both hooks compete for the same URL space, causing:
- State from one hook overwrites the other
- Unpredictable initialization behavior
- Lost state when components re-render
- Difficult debugging due to intermittent issues
β Solution: Use unique delimiters for each hook instance:
// β
SOLUTION: Unique delimiters prevent conflicts
function ParentComponent() {
const { state: userFilters } = useStatefulUrl(
{
selectedUsers: new Set<string>(),
userPage: 1,
},
{
delimiters: {
start: "__USER_FILTERS_",
end: "_USER_FILTERS__",
},
}
);
return (
<div>
<UserList filters={userFilters} />
<ProductSearch />
</div>
);
}
function ProductSearch() {
const { state: searchState } = useStatefulUrl(
{
query: "",
category: "all",
},
{
delimiters: {
start: "__PRODUCT_SEARCH_",
end: "_PRODUCT_SEARCH__",
},
}
);
}
// URL will be: #existing=params__USER_FILTERS_selectedUsers=id1,id2&userPage=2_USER_FILTERS____PRODUCT_SEARCH_query=laptop&category=electronics_PRODUCT_SEARCH__
Best Practices for Multiple Hooks:
-
Always use descriptive, unique delimiters when you might have multiple hooks:
// β Good: Descriptive and unique delimiters: { start: "__MODAL_STATE_", end: "_MODAL_STATE__" } delimiters: { start: "__FILTERS_", end: "_FILTERS__" } delimiters: { start: "__PAGINATION_", end: "_PAGINATION__" }
-
Consider a delimiter naming convention for your app:
// Pattern: __COMPONENT_PURPOSE_ delimiters: { start: "__HEADER_SEARCH_", end: "_HEADER_SEARCH__" } delimiters: { start: "__SIDEBAR_FILTERS_", end: "_SIDEBAR_FILTERS__" } delimiters: { start: "__MODAL_GALLERY_", end: "_MODAL_GALLERY__" }
-
Document delimiter usage in deeply nested component trees:
// Add comments when hooks might be nested unknowingly function DeepChild() { // NOTE: Parent components may also use useStatefulUrl // Using unique delimiters to prevent conflicts // Consider using a utility function like below or some type of hashing function (not to be confused with url hash fragments) for truly unique names const { state } = useStatefulUrl(initialState, { delimiters: { start: "__DEEP_CHILD_sdhiweruh_", end: "_DEEP_CHILD_sdhiweruh___", }, // pretend this was a proper hash }); }
-
Create utility functions for consistent delimiter generation:
// β Utility for consistent delimiter naming const createDelimiters = (componentName: string) => ({ start: `__${componentName.toUpperCase()}_`, end: `_${componentName.toUpperCase()}__`, }); // Usage const { state } = useStatefulUrl(initialState, { delimiters: createDelimiters("userFilters"), });
When You Don't Control Parent Components: If you're building a reusable component that might be used in apps with existing useStatefulUrl
usage, always use unique delimiters as a defensive practice, even if you don't know about other hooks in the component tree.
- Always check
isInitialized
before rendering state-dependent content to prevent hydration mismatches - Differentiate URL initialization from user interaction using patterns like the
hasUserInteracted
flag - Use validation in custom deserializers to handle malformed URLs gracefully
- Debounce rapid updates using the
debounceMs
option to prevent browser history pollution - Keep URLs readable by using meaningful parameter names and avoiding overly complex state
- Handle edge cases like empty arrays/sets in your serializers
- Let URLSearchParams handle encoding - never manually encode/decode values
- Consider JSON for complex values that might contain special characters
- Customize delimiters if the defaults conflict with your existing hash usage
- Use
clearHash()
to preserve existing hash content when resetting state - β¨ Feel free to define serializers inline - memoization is automatic!
The hook automatically handles performance optimizations:
- Automatic memoization of serializer functions
- Debounced URL updates to prevent excessive browser history entries
- Efficient change detection using function stringification
- SSR-safe initialization with proper hydration handling
- Modern browsers with URLSearchParams support
- Works with SSR frameworks like Next.js, Nuxt.js
Contributions welcome! Please read our contributing guide and submit PRs.
MIT License - see LICENSE file for details.