@gawryco/use-shareable-state
Version: 
The tiny, typed React hook for URL query string state. Transform your components into shareable, bookmarkable experiences with zero boilerplate.
691 lines (689 loc) • 22.4 kB
JavaScript
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
// src/useShareableState.ts
function dispatchQSChange(detail) {
  if (!isBrowser()) return;
  try {
    window.dispatchEvent(new CustomEvent("qs:changed", { detail }));
  } catch {
  }
}
function isBrowser() {
  return typeof window !== "undefined" && typeof document !== "undefined";
}
function safeGetSearchParams() {
  if (!isBrowser()) return null;
  try {
    return new URLSearchParams(window.location.search);
  } catch {
    return null;
  }
}
function applyUrl(params, action = "replace") {
  if (!isBrowser()) return;
  const url = new URL(window.location.href);
  url.search = params.toString();
  if (action === "push") {
    window.history.pushState(null, "", url.toString());
  } else {
    window.history.replaceState(null, "", url.toString());
  }
}
function useQueryParam(key, defaultValue, parse, format, normalize, opts) {
  const initial = useMemo(() => defaultValue, []);
  const [state, setState] = useState(initial);
  const keyRef = useRef(key);
  keyRef.current = key;
  const actionRef = useRef(opts?.action ?? "replace");
  actionRef.current = opts?.action ?? actionRef.current;
  const stateRef = useRef(state);
  useEffect(() => {
    stateRef.current = state;
  }, [state]);
  useEffect(() => {
    if (!isBrowser()) return;
    const params = safeGetSearchParams();
    if (!params) return;
    const raw = params.get(keyRef.current);
    if (raw === null) {
      const normalized = initial === null ? null : normalize ? normalize(initial) : initial;
      if (normalized === null) {
        params.delete(keyRef.current);
        applyUrl(params, actionRef.current);
        setState(null);
      } else {
        const seeded = format(normalized);
        if (seeded === "") {
          params.delete(keyRef.current);
        } else {
          params.set(keyRef.current, seeded);
        }
        applyUrl(params, actionRef.current);
        setState(normalized);
      }
      return;
    }
    const parsed = parse(raw);
    const value = parsed !== null ? parsed : initial;
    if (value === null) {
      setState(null);
    } else {
      setState(normalize ? normalize(value) : value);
    }
  }, [initial]);
  useEffect(() => {
    if (!isBrowser()) return;
    const handler = () => {
      const params = safeGetSearchParams();
      if (!params) return;
      const raw = params.get(keyRef.current);
      if (raw === null) return;
      const parsed = parse(raw);
      if (parsed !== null) {
        const next = normalize ? normalize(parsed) : parsed;
        const prev = stateRef.current;
        setState(next);
        dispatchQSChange({
          key: keyRef.current,
          prev,
          next,
          params: Object.fromEntries(params.entries()),
          source: "popstate",
          ts: Date.now()
        });
      }
    };
    window.addEventListener("popstate", handler);
    return () => window.removeEventListener("popstate", handler);
  }, [parse]);
  const setBoth = useCallback(
    (value) => {
      setState((prev) => {
        const rawNext = typeof value === "function" ? value(prev) : value;
        const next = rawNext === null ? null : normalize ? normalize(rawNext) : rawNext;
        const params = safeGetSearchParams();
        if (params) {
          const nextStr = format(next);
          const currentStr = params.get(keyRef.current);
          if (nextStr === "") {
            if (currentStr !== null) {
              params.delete(keyRef.current);
              applyUrl(params, actionRef.current);
              dispatchQSChange({
                key: keyRef.current,
                prev,
                next,
                params: Object.fromEntries(params.entries()),
                source: "set",
                ts: Date.now()
              });
            }
          } else if (currentStr !== nextStr) {
            params.set(keyRef.current, nextStr);
            applyUrl(params, actionRef.current);
            dispatchQSChange({
              key: keyRef.current,
              prev,
              next,
              params: Object.fromEntries(params.entries()),
              source: "set",
              ts: Date.now()
            });
          }
        }
        return next;
      });
    },
    [format]
  );
  return [state, setBoth];
}
function useQueryParamNonNull(key, defaultValue, parse, format, normalize, opts) {
  const initial = useMemo(() => defaultValue, []);
  const [state, setState] = useState(initial);
  const keyRef = useRef(key);
  keyRef.current = key;
  const actionRef = useRef(opts?.action ?? "replace");
  actionRef.current = opts?.action ?? actionRef.current;
  const stateRef = useRef(state);
  useEffect(() => {
    stateRef.current = state;
  }, [state]);
  useEffect(() => {
    if (!isBrowser()) return;
    const params = safeGetSearchParams();
    if (!params) return;
    const raw = params.get(keyRef.current);
    if (raw === null) {
      const normalized = normalize ? normalize(initial) : initial;
      const seeded = format(normalized);
      if (seeded !== "") {
        params.set(keyRef.current, seeded);
        applyUrl(params, actionRef.current);
      }
      setState(normalized);
      return;
    }
    const parsed = parse(raw);
    const value = parsed !== null ? parsed : initial;
    setState(normalize ? normalize(value) : value);
  }, [initial]);
  useEffect(() => {
    if (!isBrowser()) return;
    const handler = () => {
      const params = safeGetSearchParams();
      if (!params) return;
      const raw = params.get(keyRef.current);
      if (raw === null) {
        const next2 = normalize ? normalize(initial) : initial;
        setState(next2);
        dispatchQSChange({
          key: keyRef.current,
          prev: stateRef.current,
          next: next2,
          params: Object.fromEntries(params.entries()),
          source: "popstate",
          ts: Date.now()
        });
        return;
      }
      const parsed = parse(raw);
      const next = parsed !== null ? normalize ? normalize(parsed) : parsed : normalize ? normalize(initial) : initial;
      if (next !== stateRef.current) {
        const prev = stateRef.current;
        setState(next);
        dispatchQSChange({
          key: keyRef.current,
          prev,
          next,
          params: Object.fromEntries(params.entries()),
          source: "popstate",
          ts: Date.now()
        });
      }
    };
    window.addEventListener("popstate", handler);
    return () => window.removeEventListener("popstate", handler);
  }, [parse, initial]);
  const setBoth = useCallback(
    (value) => {
      setState((prev) => {
        const rawNext = typeof value === "function" ? value(prev) : value;
        const next = normalize ? normalize(rawNext) : rawNext;
        const params = safeGetSearchParams();
        if (params) {
          const nextStr = format(next);
          const currentStr = params.get(keyRef.current);
          if (nextStr === "") {
            if (currentStr !== null) {
              params.delete(keyRef.current);
              applyUrl(params, actionRef.current);
              dispatchQSChange({
                key: keyRef.current,
                prev,
                next,
                params: Object.fromEntries(params.entries()),
                source: "set",
                ts: Date.now()
              });
            }
          } else if (currentStr !== nextStr) {
            params.set(keyRef.current, nextStr);
            applyUrl(params, actionRef.current);
            dispatchQSChange({
              key: keyRef.current,
              prev,
              next,
              params: Object.fromEntries(params.entries()),
              source: "set",
              ts: Date.now()
            });
          }
        }
        return next;
      });
    },
    [format]
  );
  return [state, setBoth];
}
function useShareableState(key) {
  const numberBuilder = Object.assign(
    (defaultValue, opts) => {
      return useQueryParamNonNull(
        key,
        defaultValue,
        (raw) => {
          const n = Number(raw);
          return isNaN(n) ? null : n;
        },
        (v) => String(v),
        (v) => {
          let x = v;
          if (typeof opts?.min === "number") x = Math.max(opts.min, x);
          if (typeof opts?.max === "number") x = Math.min(opts.max, x);
          if (typeof opts?.step === "number" && isFinite(opts.step) && opts.step > 0) {
            const steps = Math.round(x / opts.step);
            x = steps * opts.step;
          }
          return x;
        },
        opts?.action !== void 0 ? { action: opts.action } : void 0
      );
    },
    {
      optional: (defaultValue = null, opts) => {
        return useQueryParam(
          key,
          defaultValue,
          (raw) => {
            const n = Number(raw);
            return isNaN(n) ? null : n;
          },
          (v) => v === null ? "" : String(v),
          (v) => {
            if (v === null) return v;
            let x = v;
            if (typeof opts?.min === "number") x = Math.max(opts.min, x);
            if (typeof opts?.max === "number") x = Math.min(opts.max, x);
            if (typeof opts?.step === "number" && isFinite(opts.step) && opts.step > 0) {
              const steps = Math.round(x / opts.step);
              x = steps * opts.step;
            }
            return x;
          },
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
    }
  );
  const stringBuilder = Object.assign(
    (defaultValue, opts) => {
      return useQueryParamNonNull(
        key,
        defaultValue,
        (raw) => raw,
        (v) => v,
        (v) => {
          let s = v;
          if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength));
          if (typeof opts?.minLength === "number" && s.length < opts.minLength) {
            s = s.padEnd(opts.minLength, " ");
          }
          return s;
        },
        opts?.action !== void 0 ? { action: opts.action } : void 0
      );
    },
    {
      optional: (defaultValue = null, opts) => {
        return useQueryParam(
          key,
          defaultValue,
          (raw) => raw,
          (v) => v === null ? "" : v,
          (v) => {
            if (v === null) return v;
            let s = v;
            if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength));
            if (typeof opts?.minLength === "number" && s.length < opts.minLength) {
              s = s.padEnd(opts.minLength, " ");
            }
            return s;
          },
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
    }
  );
  const booleanBuilder = Object.assign(
    (defaultValue, opts) => {
      return useQueryParamNonNull(
        key,
        defaultValue,
        (raw) => {
          const norm = raw.trim().toLowerCase();
          if (["1", "true", "t", "yes", "y"].includes(norm)) return true;
          if (["0", "false", "f", "no", "n"].includes(norm)) return false;
          return null;
        },
        (v) => v ? "1" : "0",
        void 0,
        opts?.action !== void 0 ? { action: opts.action } : void 0
      );
    },
    {
      optional: (defaultValue = null, opts) => {
        return useQueryParam(
          key,
          defaultValue,
          (raw) => {
            const norm = raw.trim().toLowerCase();
            if (["1", "true", "t", "yes", "y"].includes(norm)) return true;
            if (["0", "false", "f", "no", "n"].includes(norm)) return false;
            return null;
          },
          (v) => v === null ? "" : v ? "1" : "0",
          void 0,
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
    }
  );
  const dateBuilder = Object.assign(
    (defaultValue, opts) => {
      return useQueryParamNonNull(
        key,
        defaultValue,
        (raw) => {
          const d = new Date(raw);
          return isNaN(d.getTime()) ? null : d;
        },
        (v) => {
          const yyyy = v.getUTCFullYear();
          const mm = String(v.getUTCMonth() + 1).padStart(2, "0");
          const dd = String(v.getUTCDate()).padStart(2, "0");
          return `${yyyy}-${mm}-${dd}`;
        },
        (v) => {
          let d = v;
          if (opts?.min && d < opts.min) d = opts.min;
          if (opts?.max && d > opts.max) d = opts.max;
          return d;
        },
        opts?.action !== void 0 ? { action: opts.action } : void 0
      );
    },
    {
      optional: (defaultValue = null, opts) => {
        return useQueryParam(
          key,
          defaultValue,
          (raw) => {
            const d = new Date(raw);
            return isNaN(d.getTime()) ? null : d;
          },
          (v) => {
            if (v === null) return "";
            const yyyy = v.getUTCFullYear();
            const mm = String(v.getUTCMonth() + 1).padStart(2, "0");
            const dd = String(v.getUTCDate()).padStart(2, "0");
            return `${yyyy}-${mm}-${dd}`;
          },
          (v) => {
            if (v === null) return v;
            let d = v;
            if (opts?.min && d < opts.min) d = opts.min;
            if (opts?.max && d > opts.max) d = opts.max;
            return d;
          },
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
    }
  );
  return {
    /**
     * Number state builder. Use .number(defaultValue) for non-nullable or .number().optional() for nullable.
     * 
     * @example
     * const [count, setCount] = useShareableState('count').number(0); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').number().optional(); // nullable
     */
    number: numberBuilder,
    /**
     * String state builder. Use .string(defaultValue) for non-nullable or .string().optional() for nullable.
     * 
     * @example
     * const [name, setName] = useShareableState('name').string(''); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').string().optional(); // nullable
     */
    string(defaultValue, opts) {
      if (defaultValue !== void 0) {
        return useQueryParamNonNull(
          key,
          defaultValue,
          (raw) => raw,
          (v) => v,
          (v) => {
            let s = v;
            if (typeof opts?.maxLength === "number") s = s.slice(0, Math.max(0, opts.maxLength));
            if (typeof opts?.minLength === "number" && s.length < opts.minLength) {
              s = s.padEnd(opts.minLength, " ");
            }
            return s;
          },
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
      return stringBuilder;
    },
    /**
     * Boolean state builder. Use .boolean(defaultValue) for non-nullable or .boolean().optional() for nullable.
     * 
     * @example
     * const [active, setActive] = useShareableState('active').boolean(false); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').boolean().optional(); // nullable
     */
    boolean: booleanBuilder,
    /**
     * Date state builder. Use .date(defaultValue) for non-nullable or .date().optional() for nullable.
     * 
     * @example
     * const [start, setStart] = useShareableState('start').date(new Date()); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').date().optional(); // nullable
     */
    date(defaultValue, opts) {
      if (defaultValue !== void 0) {
        return useQueryParamNonNull(
          key,
          defaultValue,
          (raw) => {
            const d = new Date(raw);
            return isNaN(d.getTime()) ? null : d;
          },
          (v) => {
            const yyyy = v.getUTCFullYear();
            const mm = String(v.getUTCMonth() + 1).padStart(2, "0");
            const dd = String(v.getUTCDate()).padStart(2, "0");
            return `${yyyy}-${mm}-${dd}`;
          },
          (v) => {
            let d = v;
            if (opts?.min && d < opts.min) d = opts.min;
            if (opts?.max && d > opts.max) d = opts.max;
            return d;
          },
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
      return dateBuilder;
    },
    /**
     * Enum state builder. Binds a string literal union (enum-like) to a query param.
     * 
     * @template U extends string
     * @example
     * type Theme = 'light' | 'dark';
     * const [theme, setTheme] = useShareableState('theme').enum<Theme>(['light','dark'], 'light'); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').enum<Theme>().optional(['light','dark']); // nullable
     */
    enum(allowed, defaultValue, opts) {
      if (allowed !== void 0 && defaultValue !== void 0) {
        return useQueryParamNonNull(
          key,
          defaultValue,
          (raw) => allowed.includes(raw) ? raw : null,
          (v) => v,
          void 0,
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
      const enumBuilder = Object.assign(
        (allowed2, defaultValue2, opts2) => {
          return useQueryParamNonNull(
            key,
            defaultValue2,
            (raw) => allowed2.includes(raw) ? raw : null,
            (v) => v,
            void 0,
            opts2?.action !== void 0 ? { action: opts2.action } : void 0
          );
        },
        {
          optional: (allowed2, defaultValue2 = null, opts2) => {
            return useQueryParam(
              key,
              defaultValue2,
              (raw) => allowed2.includes(raw) ? raw : null,
              (v) => v === null ? "" : v,
              void 0,
              opts2?.action !== void 0 ? { action: opts2.action } : void 0
            );
          }
        }
      );
      return enumBuilder;
    },
    /**
     * Custom state builder. Provide your own parse/format functions.
     * 
     * @template T
     * @example
     * const [ids, setIds] = useShareableState('ids').custom<number[]>([], parse, format); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').custom<number[]>().optional(null, parse, format); // nullable
     */
    custom() {
      const customBuilder = Object.assign(
        (defaultValue, parse, format, opts) => {
          return useQueryParamNonNull(
            key,
            defaultValue,
            parse,
            format,
            void 0,
            opts?.action !== void 0 ? { action: opts.action } : void 0
          );
        },
        {
          optional: (defaultValue, parse, format, opts) => {
            return useQueryParam(
              key,
              defaultValue,
              parse,
              format,
              void 0,
              opts?.action !== void 0 ? { action: opts.action } : void 0
            );
          }
        }
      );
      return customBuilder;
    },
    /**
     * JSON state builder. Binds a JSON-serializable value to a query param.
     * 
     * @template T
     * @example
     * const [data, setData] = useShareableState('data').json<{q: string}>({q: ''}); // non-nullable
     * const [optional, setOptional] = useShareableState('opt').json<{q: string}>().optional(); // nullable
     */
    json(defaultValue, opts) {
      if (defaultValue !== void 0) {
        const parseJson = (raw) => {
          try {
            const parsed = opts?.parse ? opts.parse(raw) : JSON.parse(raw);
            if (opts?.validate) {
              return opts.validate(parsed) ? parsed : null;
            }
            return parsed;
          } catch {
            return null;
          }
        };
        const formatJson = (value) => {
          if (opts?.omitEmpty && opts.omitEmpty(value)) return "";
          try {
            return opts?.stringify ? opts.stringify(value) : JSON.stringify(value);
          } catch {
            return "";
          }
        };
        return useQueryParamNonNull(
          key,
          defaultValue,
          parseJson,
          formatJson,
          void 0,
          opts?.action !== void 0 ? { action: opts.action } : void 0
        );
      }
      const jsonBuilder = Object.assign(
        (defaultValue2, opts2) => {
          const parseJson = (raw) => {
            try {
              const parsed = opts2?.parse ? opts2.parse(raw) : JSON.parse(raw);
              if (opts2?.validate) {
                return opts2.validate(parsed) ? parsed : null;
              }
              return parsed;
            } catch {
              return null;
            }
          };
          const formatJson = (value) => {
            if (opts2?.omitEmpty && opts2.omitEmpty(value)) return "";
            try {
              return opts2?.stringify ? opts2.stringify(value) : JSON.stringify(value);
            } catch {
              return "";
            }
          };
          return useQueryParamNonNull(
            key,
            defaultValue2,
            parseJson,
            formatJson,
            void 0,
            opts2?.action !== void 0 ? { action: opts2.action } : void 0
          );
        },
        {
          optional: (defaultValue2 = null, opts2) => {
            const parseJson = (raw) => {
              try {
                const parsed = opts2?.parse ? opts2.parse(raw) : JSON.parse(raw);
                if (opts2?.validate) {
                  return opts2.validate(parsed) ? parsed : null;
                }
                return parsed;
              } catch {
                return null;
              }
            };
            const formatJson = (value) => {
              if (value === null) return "";
              if (opts2?.omitEmpty && opts2.omitEmpty(value)) return "";
              try {
                return opts2?.stringify ? opts2.stringify(value) : JSON.stringify(value);
              } catch {
                return "";
              }
            };
            return useQueryParam(
              key,
              defaultValue2,
              parseJson,
              formatJson,
              void 0,
              opts2?.action !== void 0 ? { action: opts2.action } : void 0
            );
          }
        }
      );
      return jsonBuilder;
    }
  };
}
export { useShareableState };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map