statelet
Version:
Dead simple state management built on kvkit - composable, reactive, shareable. Supports Standard Schema (Valibot, Zod, ArkType).
241 lines (240 loc) • 7.41 kB
JavaScript
import { flatCodec, jsonCodec } from '@kvkit/codecs';
/**
* Sync state with URL search parameters (?key=value)
*
* @example
* const searchState = compose(
* state({ query: '', page: 1 }),
* withUrlParams()
* );
*/
export function withUrlParams(codec = flatCodec()) {
return (base) => {
if (typeof window === 'undefined')
return base; // SSR safe
// Initial sync from URL
const params = new URLSearchParams(window.location.search);
const data = {};
params.forEach((value, key) => data[key] = value);
const decoded = codec.decode(data);
base.set({ ...base.get(), ...decoded });
// Listen to state changes and update URL
base.subscribe((value) => {
const encoded = codec.encode(value);
const newParams = new URLSearchParams();
Object.entries(encoded).forEach(([key, val]) => {
if (val !== undefined && val !== null) {
newParams.set(key, String(val));
}
});
const url = new URL(window.location.href);
url.search = newParams.toString();
window.history.replaceState(null, '', url.toString());
});
// Listen to URL changes (back/forward)
const handlePopState = () => {
const params = new URLSearchParams(window.location.search);
const data = {};
params.forEach((value, key) => data[key] = value);
const decoded = codec.decode(data);
base.set({ ...base.get(), ...decoded });
};
window.addEventListener('popstate', handlePopState);
return {
...base,
subscribe(callback) {
const unsub = base.subscribe(callback);
return () => {
unsub();
window.removeEventListener('popstate', handlePopState);
};
}
};
};
}
/**
* Sync state with URL hash parameters (#key=value)
*
* @example
* const tabState = compose(
* state({ tab: 'home', modal: false }),
* withHashParams()
* );
*/
export function withHashParams(codec = flatCodec()) {
return (base) => {
if (typeof window === 'undefined')
return base; // SSR safe
// Initial sync from hash
const params = new URLSearchParams(window.location.hash.slice(1));
const data = {};
params.forEach((value, key) => data[key] = value);
const decoded = codec.decode(data);
base.set({ ...base.get(), ...decoded });
// Listen to state changes and update hash
base.subscribe((value) => {
const encoded = codec.encode(value);
const newParams = new URLSearchParams();
Object.entries(encoded).forEach(([key, val]) => {
if (val !== undefined && val !== null) {
newParams.set(key, String(val));
}
});
window.location.hash = newParams.toString();
});
// Listen to hash changes
const handleHashChange = () => {
const params = new URLSearchParams(window.location.hash.slice(1));
const data = {};
params.forEach((value, key) => data[key] = value);
const decoded = codec.decode(data);
base.set({ ...base.get(), ...decoded });
};
window.addEventListener('hashchange', handleHashChange);
return {
...base,
subscribe(callback) {
const unsub = base.subscribe(callback);
return () => {
unsub();
window.removeEventListener('hashchange', handleHashChange);
};
}
};
};
}
/**
* Persist state to localStorage
*
* @example
* const userPrefs = compose(
* state({ theme: 'light', fontSize: 14 }),
* withStorage('user-preferences')
* );
*/
export function withStorage(key, codec = jsonCodec()) {
return (base) => {
if (typeof window === 'undefined')
return base; // SSR safe
// Hydrate from storage
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
const decoded = codec.decode(parsed);
base.set({ ...base.get(), ...decoded });
}
}
catch {
// Ignore errors
}
// Persist changes
base.subscribe((value) => {
try {
const encoded = codec.encode(value);
localStorage.setItem(key, JSON.stringify(encoded));
}
catch {
// Ignore errors (quota exceeded, etc.)
}
});
return base;
};
}
/**
* Run side effects when state changes
*
* @example
* const analytics = compose(
* state({ page: 'home' }),
* withEffect((newValue, prevValue) => {
* gtag('event', 'page_view', { page: newValue.page });
* })
* );
*/
export function withEffect(effect, options = {}) {
return (base) => {
let previous = base.get();
if (options.immediate) {
effect(previous, previous);
}
base.subscribe((current) => {
effect(current, previous);
previous = current;
});
return base;
};
}
/**
* Validate state changes with Standard Schema
*
* Supports Valibot, Zod, ArkType, and any other Standard Schema-compliant library
*
* @example
* // With Valibot
* const userForm = compose(
* state({ name: '', email: '', age: 0 }),
* withValidation(v.object({
* name: v.string(),
* email: v.pipe(v.string(), v.email()),
* age: v.number()
* }))
* );
*
* @example
* // With Zod
* const userForm = compose(
* state({ name: '', email: '', age: 0 }),
* withValidation(z.object({
* name: z.string(),
* email: z.string().email(),
* age: z.number()
* }))
* );
*/
export function withValidation(schema) {
return (base) => {
return {
...base,
set(valueOrUpdater) {
const next = typeof valueOrUpdater === 'function'
? valueOrUpdater(base.get())
: valueOrUpdater;
const result = schema['~standard'].validate(next);
if ('issues' in result) {
// Create a validation error similar to what Valibot/Zod would throw
const error = new Error('Validation failed');
error.issues = result.issues;
throw error;
}
base.set(result.value);
}
};
};
}
/**
* Debounce state updates
*
* @example
* const searchState = compose(
* state({ query: '' }),
* withDebounce(300), // Wait 300ms before updating
* withUrlParams()
* );
*/
export function withDebounce(ms) {
return (base) => {
let timeout;
return {
...base,
set(valueOrUpdater) {
clearTimeout(timeout);
timeout = setTimeout(() => {
base.set(valueOrUpdater);
}, ms);
}
};
};
}
// Re-export kvkit codecs for convenience
export { flatCodec, jsonCodec } from '@kvkit/codecs';