Skip to content

nitedani/zustand-querystring

Repository files navigation

zustand-querystring

Zustand middleware for URL query string sync.

npm install zustand-querystring

Usage

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=2

Options

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=active

syncNull / 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=daily

url

For SSR, pass the request URL:

querystring(store, { url: request.url, select: () => ({ search: true }) });

How State Syncs

  1. On page load: URL → State
  2. 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 like os=CentOS is indistinguishable from a scalar string. Use 'repeat' for dynamic keys, or normalize with Array.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.initialState available 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=20

Next.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';

Playground · GitHub

About

A Zustand middleware that syncs the store with the querystring.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors