query-synchronize
v0.1.5
Published
Framework-agnostic URL query <-> filters synchronization
Maintainers
Readme
query-synchronize
Framework-agnostic library for sync between URL query params and filter state.
Why
- One core API for Vue, React, Svelte, vanilla JS.
- Typed parsing/serialization through schema codecs.
- Two-way flow:
- URL -> filters
- filters -> URL
Install
npm i query-synchronizeCore usage
import { createFilterSync, numberCodec, stringCodec, numberArrayCodec, type QueryAdapter } from 'query-synchronize';
type Filters = {
page: number | null;
search: string | null;
tagIds: number[];
};
const schema = {
page: numberCodec(1),
search: stringCodec(null),
tagIds: numberArrayCodec([]),
};
const adapter: QueryAdapter = {
readQuery() {
const params = new URLSearchParams(window.location.search);
const query: Record<string, string | string[] | undefined> = {};
for (const key of params.keys()) {
const values = params.getAll(key);
query[key] = values.length > 1 ? values : values[0];
}
return query;
},
writeQuery(query, mode = 'push') {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value === undefined) return;
if (Array.isArray(value)) value.forEach(v => params.append(key, v));
else params.set(key, value);
});
const url = `${window.location.pathname}?${params.toString()}`;
if (mode === 'replace') history.replaceState(null, '', url);
else history.pushState(null, '', url);
},
subscribe(onChange) {
window.addEventListener('popstate', onChange);
return () => window.removeEventListener('popstate', onChange);
},
};
const sync = createFilterSync<Filters>(adapter, schema);
const filters = sync.getFilters();
sync.setFilters({ page: 2 });setFilters, replaceFilters и clear автоматически пропускают запись в URL, если query фактически не изменился.
For reactive stores (Vue/Nuxt), use setFiltersFromQuery(query, filters, schema) to mutate only changed schema fields.
Vue adapter example
import type { QueryAdapter } from 'query-synchronize';
export function useVueQueryAdapter(): QueryAdapter {
const router = useRouter();
const route = useRoute();
return {
readQuery() {
const q: Record<string, string | string[] | undefined> = {};
for (const [k, v] of Object.entries(route.query)) {
if (Array.isArray(v)) q[k] = v.map(String);
else if (v == null) q[k] = undefined;
else q[k] = String(v);
}
return q;
},
writeQuery(query, mode = 'replace') {
if (mode === 'replace') router.replace({ query });
else router.push({ query });
},
};
}Vue/Nuxt project-style example
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import {
booleanCodec,
bitCodec,
createFilterSync,
enumCodec,
jsonCodec,
numberArrayCodec,
numberCodec,
setFiltersFromQuery,
stringArrayCodec,
stringCodec,
} from 'query-synchronize';
import { useVueQueryAdapter } from '~/shared/composables/useVueQueryAdapter';
type Filters = {
page: number | null;
search: string | null;
onlyMine: boolean | null;
statuses: number[];
state_id: string[];
users_reclamations: 0 | 1;
tab: 'in_progress' | 'done' | 'all';
has_not_answer: 0 | 1;
meta: Record<string, unknown> | null;
};
const route = useRoute();
const adapter = useVueQueryAdapter();
const filters = ref<Filters>({
page: 1,
search: null,
onlyMine: null,
statuses: [],
state_id: [],
users_reclamations: 0,
tab: 'all',
has_not_answer: 0,
meta: null,
});
const schema = {
page: numberCodec(1),
search: stringCodec(),
onlyMine: booleanCodec(),
statuses: numberArrayCodec([]),
state_id: stringArrayCodec([]),
users_reclamations: bitCodec(0),
tab: enumCodec(['in_progress', 'done', 'all'] as const, 'all'),
has_not_answer: bitCodec(0),
meta: jsonCodec<Record<string, unknown> | null>(null),
};
const sync = createFilterSync(adapter, schema);
watch(
() => route.query,
() => {
// Mutates only changed schema fields.
// Extra params like ?modal=... do not trigger unnecessary filter updates.
setFiltersFromQuery(adapter.readQuery(), filters.value, schema);
},
{ deep: true, immediate: true },
);
watch(
filters,
() => {
// Updates only schema keys and preserves unknown query params.
sync.replaceFilters(filters.value, { mode: 'push' });
},
{ deep: true },
);React adapter example
import type { QueryAdapter } from 'query-synchronize';
import { useSearchParams } from 'react-router-dom';
export function useReactRouterAdapter(): QueryAdapter {
const [params, setParams] = useSearchParams();
return {
readQuery() {
const query: Record<string, string | string[] | undefined> = {};
for (const key of params.keys()) {
const values = params.getAll(key);
query[key] = values.length > 1 ? values : values[0];
}
return query;
},
writeQuery(query, mode = 'push') {
const next = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value === undefined) return;
if (Array.isArray(value)) value.forEach(v => next.append(key, v));
else next.set(key, value);
});
setParams(next, { replace: mode === 'replace' });
},
};
}API
numberCodec(defaultValue?)stringCodec(defaultValue?)booleanCodec(defaultValue?)stringArrayCodec(defaultValue?)numberArrayCodec(defaultValue?)jsonCodec(defaultValue?)enumCodec(allowed, defaultValue)bitCodec(defaultValue?)parseFilters(query, schema)setFiltersFromQuery(query, filters, schema, options?)filtersToQuery(filters, schema, currentQuery?, options?)clearQuery(currentQuery, options?)createFilterSync(adapter, schema)InferFilterValues<typeof schema>
