zustand-querystring
v0.7.0
Published
Zustand middleware for URL query string sync.
Readme
zustand-querystring
Zustand middleware for URL query string sync.
npm install zustand-querystringUsage
import { create } from 'zustand';
import { querystring } from 'zustand-querystring';
const useStore = create(
querystring(
set => ({
search: '',
page: 1,
setSearch: search => set({ search }),
setPage: page => set({ page }),
}),
{
select: () => ({ search: true, page: true }),
},
),
);
// URL: ?search=hello&page=2Options
querystring(storeCreator, {
select: undefined, // which fields to sync
key: false, // false | 'state'
prefix: '', // prefix for URL params
format: marked, // serialization format
map: undefined, // bidirectional store ↔ URL mapping
syncNull: false, // sync null values
syncUndefined: false, // sync undefined values
url: undefined, // request URL for SSR
});select
Controls which state fields sync to URL. Receives pathname, returns object with true for fields to sync.
// All fields
select: () => ({ search: true, page: true, filters: true });
// Route-based
select: pathname => ({
search: true,
filters: pathname.startsWith('/products'),
adminSettings: pathname.startsWith('/admin'),
});
// Nested fields
select: () => ({
user: {
name: true,
settings: { theme: true },
},
});key
false(default): Each field becomes a separate URL param?search=hello&page=2&filters.sort=name'state'(or any string): All state in one param?state=search%3Dhello%2Cpage%3A2
prefix
Adds prefix to all params. Use when multiple stores share URL.
querystring(storeA, { prefix: 'a_', select: () => ({ search: true }) });
querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) });
// URL: ?a_search=hello&b_filter=activesyncNull / syncUndefined
By default, null and undefined reset to initial state (removed from URL). Set to true to write them.
map
Bidirectional mapping between store state and URL state. Use when the URL should represent a different shape than the store — e.g., a store keyed by dynamic IDs where the URL should only reflect the active entry.
to receives the selected state and pathname, returns the URL shape. from receives the parsed URL state and pathname, returns store state to merge. Types flow automatically: from's parameter type is inferred from to's return type.
interface Store {
filtersByOperation: Record<string, { filters: string[] }>;
aggregationByOperation: Record<string, string>;
setFilters: (opId: string, filters: string[]) => void;
}
const useStore = create<Store>()(
querystring(
set => ({
filtersByOperation: {},
aggregationByOperation: {},
setFilters: (opId, filters) =>
set(s => ({
filtersByOperation: { ...s.filtersByOperation, [opId]: { filters } },
})),
}),
{
key: 'state',
select: pathname => ({
filtersByOperation: pathname.startsWith('/view/'),
aggregationByOperation: pathname.startsWith('/view/'),
}),
map: {
to: (state, pathname) => {
const id = pathname.split('/').pop()!;
return {
filters: state.filtersByOperation?.[id]?.filters,
aggregation: state.aggregationByOperation?.[id],
};
},
from: (urlState, pathname) => {
// urlState is typed as { filters: string[] | undefined, aggregation: string | undefined }
const id = pathname.split('/').pop()!;
return {
...(urlState.filters
? { filtersByOperation: { [id]: { filters: urlState.filters } } }
: {}),
...(urlState.aggregation
? { aggregationByOperation: { [id]: urlState.aggregation } }
: {}),
};
},
},
},
),
);
// On /view/DAM_v1 → URL: ?state=filters@price_>10~,aggregation=dailyurl
For SSR, pass the request URL:
querystring(store, { url: request.url, select: () => ({ search: true }) });How State Syncs
- On page load: URL → State
- On state change: State → URL (via
replaceState)
Only values different from initial state are written to URL:
// Initial: { search: '', page: 1, sort: 'date' }
// Current: { search: 'hello', page: 1, sort: 'name' }
// URL: ?search=hello&sort=name
// (page omitted - matches initial)Type handling:
- Objects: recursively diffed
- Arrays, Dates: compared as whole values
- Functions: never synced
Formats
Three built-in formats:
| Format | Example Output |
| -------- | ---------------------------- |
| marked | count:5,tags@a,b~ |
| plain | count=5&tags=a&tags=b |
| json | count=5&tags=%5B%22a%22%5D |
import { marked } from 'zustand-querystring/format/marked';
import { plain } from 'zustand-querystring/format/plain';
import { json } from 'zustand-querystring/format/json';
querystring(store, { format: plain });Marked Format (default)
Type markers: : primitive, = string, @ array, . object
Delimiters: , separator, ~ terminator, _ escape
import { createFormat } from 'zustand-querystring/format/marked';
const format = createFormat({
typeObject: '.',
typeArray: '@',
typeString: '=',
typePrimitive: ':',
separator: ',',
terminator: '~',
escapeChar: '_',
datePrefix: 'D',
});Plain Format
Dot notation for nesting, repeated keys for arrays.
import { createFormat } from 'zustand-querystring/format/plain';
const format = createFormat({
entrySeparator: ',', // between entries in namespaced mode
nestingSeparator: '.', // for nested keys
arraySeparator: 'repeat', // 'repeat' for ?tags=a&tags=b, or ',' for ?tags=a,b
escapeChar: '_',
nullString: 'null',
undefinedString: 'undefined',
infinityString: 'Infinity', // string representation of Infinity
negativeInfinityString: '-Infinity',
nanString: 'NaN',
});Note on
arraySeparator: ',': With comma-separated arrays and dynamic keys (e.g.,initialState: { filters: {} }), a single array value likeos=CentOSis indistinguishable from a scalar string. Use'repeat'for dynamic keys, or normalize withArray.isArray(val) ? val : [val].
JSON Format
URL-encoded JSON. No configuration.
Custom Format
Implement QueryStringFormat:
import type {
QueryStringFormat,
QueryStringParams,
ParseContext,
} from 'zustand-querystring';
const myFormat: QueryStringFormat = {
// For key: 'state' (namespaced mode)
stringify(state: object): string {
return encodeURIComponent(JSON.stringify(state));
},
parse(value: string, ctx?: ParseContext): object {
return JSON.parse(decodeURIComponent(value));
},
// For key: false (standalone mode)
stringifyStandalone(state: object): QueryStringParams {
const result: QueryStringParams = {};
for (const [key, value] of Object.entries(state)) {
result[key] = [encodeURIComponent(JSON.stringify(value))];
}
return result;
},
parseStandalone(params: QueryStringParams, ctx: ParseContext): object {
const result: Record<string, unknown> = {};
for (const [key, values] of Object.entries(params)) {
result[key] = JSON.parse(decodeURIComponent(values[0]));
}
return result;
},
};
querystring(store, { format: myFormat });Types:
QueryStringParams=Record<string, string[]>(values always arrays)ctx.initialStateavailable for type coercion
Examples
Search with reset
const useStore = create(
querystring(
set => ({
query: '',
page: 1,
setQuery: query => set({ query, page: 1 }), // reset page on new query
setPage: page => set({ page }),
}),
{ select: () => ({ query: true, page: true }) },
),
);Multiple stores with prefixes
const useFilters = create(
querystring(filtersStore, {
prefix: 'f_',
select: () => ({ category: true, price: true }),
}),
);
const usePagination = create(
querystring(paginationStore, {
prefix: 'p_',
select: () => ({ page: true, limit: true }),
}),
);
// URL: ?f_category=shoes&f_price=100&p_page=2&p_limit=20Next.js SSR
// app/page.tsx
export default async function Page({ searchParams }) {
// Store reads from URL on init
}Exports
// Middleware
import { querystring } from 'zustand-querystring';
// Formats
import { marked, createFormat } from 'zustand-querystring/format/marked';
import { plain, createFormat } from 'zustand-querystring/format/plain';
import { json } from 'zustand-querystring/format/json';
// Types
import type {
QueryStringOptions,
QueryStringMap,
QueryStringFormat,
QueryStringParams,
ParseContext,
} from 'zustand-querystring';