npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gawryco/use-shareable-state

v0.1.3

Published

The tiny, typed React hook for URL query string state. Transform your components into shareable, bookmarkable experiences with zero boilerplate.

Readme

use-shareable-state

The tiny, typed React hook for URL query string state

Transform your components into shareable, bookmarkable experiences with zero boilerplate.

npm version Bundle size TypeScript CI License: MIT

Examples → | API Docs →


✨ Why useShareableState?

Turn this 😰:

// Manual URL state management
const [filters, setFilters] = useState({ search: '', category: 'all' });
const [page, setPage] = useState(1);

// Manually sync with URL on mount
useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  setFilters({
    search: params.get('search') || '',
    category: params.get('category') || 'all',
  });
  setPage(Number(params.get('page')) || 1);
}, []);

// Manually update URL on changes
useEffect(() => {
  const params = new URLSearchParams();
  if (filters.search) params.set('search', filters.search);
  if (filters.category !== 'all') params.set('category', filters.category);
  if (page > 1) params.set('page', String(page));
  window.history.replaceState({}, '', `?${params}`);
}, [filters, page]);

Into this 🚀:

// Automatic URL state management with type safety
const [search, setSearch] = useShareableState('search').string('');
const [category, setCategory] = useShareableState('category').string('all');
const [page, setPage] = useShareableState('page').number(1);
// All values are non-nullable by default, perfect type inference!

🎯 Features

🏗️ Type-Safe Builders

Built-in support for number, string, boolean, date, enum, json, and custom types. Non-nullable by default, explicit .optional() for nullable fields.

Zero Boilerplate

One-liner setup per query parameter. React-style setters with automatic URL synchronization.

🔄 Navigation Support

Automatically handles browser back/forward navigation, keeping state and URL in perfect sync.

🌐 SSR Ready

Safe guards for server-side rendering. Should work with Next.js, Remix, and other React frameworks.

📦 Tiny Bundle

< 2kB gzipped. Tree-shakeable ESM and CJS builds with zero dependencies.

🔧 Framework Agnostic

Pure URL manipulation. Works with any React app, any router, any bundler.

📦 Installation

# npm
npm install @gawryco/use-shareable-state

# pnpm
pnpm add @gawryco/use-shareable-state

# yarn
yarn add @gawryco/use-shareable-state

Requirements: React ≥ 17.0.0

🚀 Quick Start

import { useShareableState } from '@gawryco/use-shareable-state';

function SearchPage() {
  // Typed string state synced with ?q=...
  const [query, setQuery] = useShareableState('q').string('');

  // Typed number state synced with ?page=...
  const [page, setPage] = useShareableState('page').number(1);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />

      <button onClick={() => setPage((p) => p + 1)}>Page {page}</button>

      {/* URL automatically updates: ?q=react&page=2 */}
    </div>
  );
}

That's it! 🎉 The URL updates automatically, browser navigation works, and state persists across page refreshes.

🎨 Examples

🔍 Search & Filters

function ProductSearch() {
  const [search, setSearch] = useShareableState('q').string('');
  const [category, setCategory] = useShareableState('cat').enum<
    'electronics' | 'clothing' | 'books'
  >(['electronics', 'clothing', 'books'], 'electronics');
  const [minPrice, setMinPrice] = useShareableState('min').number(0, { min: 0 });
  const [inStock, setInStock] = useShareableState('stock').boolean(false);

  return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search products..."
      />

      <select
        value={category}
        onChange={(e) => setCategory(e.target.value as 'electronics' | 'clothing' | 'books')}
      >
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
        <option value="books">Books</option>
      </select>

      <input
        type="number"
        value={minPrice}
        onChange={(e) => setMinPrice(Number(e.target.value))}
        placeholder="Min price"
      />

      <label>
        <input type="checkbox" checked={inStock} onChange={(e) => setInStock(e.target.checked)} />
        In stock only
      </label>

      {/* URL: ?q=laptop&cat=electronics&min=500&stock=1 */}
    </div>
  );
}

🔘 Optional Params (Nullable with .optional())

function OptionalParams() {
  // Use .optional() for nullable params - Zod-like pattern!
  const [search, setSearch] = useShareableState('q').string().optional();
  const [category, setCategory] = useShareableState('cat')
    .enum<'electronics' | 'clothing' | 'books'>()
    .optional(['electronics', 'clothing', 'books']);
  const [minPrice, setMinPrice] = useShareableState('min').number().optional(undefined, { min: 0 });

  // URL examples:
  // - Initially:    (no params)
  // - After search: ?q=laptop
  // - After picks:  ?q=laptop&cat=electronics&min=500

  return (
    <div>
      <input
        value={search ?? ''}
        onChange={(e) => setSearch(e.target.value || null)}
        placeholder="Search..."
      />
      <select
        value={category ?? ''}
        onChange={(e) =>
          setCategory(
            e.target.value ? (e.target.value as 'electronics' | 'clothing' | 'books') : null,
          )
        }
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
        <option value="books">Books</option>
      </select>
      <input
        type="number"
        value={minPrice ?? ''}
        onChange={(e) => setMinPrice(e.target.value ? Number(e.target.value) : null)}
        placeholder="Min price"
      />
    </div>
  );
}

🧭 Push History Entries

function SearchWithHistory() {
  // Use action: 'push' to add a new history entry on each update
  const [q, setQ] = useShareableState('q').string('', { action: 'push' });
  const [page, setPage] = useShareableState('page').number(1, { action: 'push' });

  // Hitting the browser Back button will step through previous q/page states

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search..." />
      <button onClick={() => setPage((p) => p + 1)}>Next page</button>
    </div>
  );
}

📅 Date Ranges

function EventCalendar() {
  const [startDate, setStartDate] = useShareableState('from').date(new Date('2024-01-01'), {
    min: new Date('2024-01-01'),
    max: new Date('2024-12-31'),
  });

  const [endDate, setEndDate] = useShareableState('to').date(new Date('2024-12-31'));

  return (
    <div>
      <input
        type="date"
        value={startDate.toISOString().slice(0, 10)}
        onChange={(e) => setStartDate(new Date(e.target.value))}
      />
      <input
        type="date"
        value={endDate.toISOString().slice(0, 10)}
        onChange={(e) => setEndDate(new Date(e.target.value))}
      />

      {/* URL: ?from=2024-06-01&to=2024-06-30 */}
    </div>
  );
}

🗂️ Complex Objects with JSON

interface TableConfig {
  sortBy: string;
  sortOrder: 'asc' | 'desc';
  columns: string[];
}

function DataTable() {
  const [config, setConfig] = useShareableState('config').json<TableConfig>(
    {
      sortBy: 'name',
      sortOrder: 'asc',
      columns: ['name', 'email', 'role'],
    },
    {
      // Only add to URL when config differs from default
      omitEmpty: (cfg) =>
        cfg.sortBy === 'name' && cfg.sortOrder === 'asc' && cfg.columns.length === 3,
    },
  );

  const updateSort = (field: string) => {
    setConfig((prev) => ({
      ...prev,
      sortBy: field,
      sortOrder: prev.sortBy === field && prev.sortOrder === 'asc' ? 'desc' : 'asc',
    }));
  };

  return (
    <table>
      <thead>
        <tr>
          {config.columns.map((col) => (
            <th key={col} onClick={() => updateSort(col)}>
              {col} {config.sortBy === col && (config.sortOrder === 'asc' ? '↑' : '↓')}
            </th>
          ))}
        </tr>
      </thead>
      {/* ... table body */}
    </table>
  );
}

🎛️ Custom Serialization

// For comma-separated arrays
function TagFilter() {
  const [tags, setTags] = useShareableState('tags').custom<string[]>(
    [],
    // Parse: "react,typescript,hooks" → ["react", "typescript", "hooks"]
    (str) => (str ? str.split(',').filter(Boolean) : []),
    // Format: ["react", "hooks"] → "react,hooks"
    (arr) => (arr.length > 0 ? arr.join(',') : ''),
  );

  const addTag = (tag: string) => setTags((prev) => [...prev, tag]);
  const removeTag = (tag: string) => setTags((prev) => prev.filter((t) => t !== tag));

  return (
    <div>
      {tags.map((tag) => (
        <span key={tag} onClick={() => removeTag(tag)}>
          {tag} ×
        </span>
      ))}
      {/* URL: ?tags=react,typescript,hooks */}
    </div>
  );
}

📚 API Reference

🏗️ Type Builders

Pattern: Non-nullable by default, explicit .optional() for nullable fields.

number(defaultValue, options?) - Non-nullable

const [count, setCount] = useShareableState('count').number(0, {
  min: 0, // Clamp to minimum value
  max: 100, // Clamp to maximum value
  step: 5, // Round to nearest step
  action: 'replace', // 'replace' | 'push'
});
// count: number (never null)

number().optional(defaultValue?, options?) - Nullable

const [count, setCount] = useShareableState('count').number().optional(null, {
  min: 0,
  max: 100,
  step: 5,
});
// count: number | null

string(defaultValue, options?) - Non-nullable

const [name, setName] = useShareableState('name').string('', {
  maxLength: 50, // Truncate if too long
  minLength: 2, // Pad with spaces if too short
  action: 'replace',
});
// name: string (never null)

string().optional(defaultValue?, options?) - Nullable

const [name, setName] = useShareableState('name').string().optional();
// name: string | null

boolean(defaultValue) - Non-nullable

const [enabled, setEnabled] = useShareableState('enabled').boolean(false);
// enabled: boolean (never null)
// Accepts: '1', 'true', 't', 'yes', 'y' (truthy)
//         '0', 'false', 'f', 'no', 'n' (falsy)

boolean().optional(defaultValue?) - Nullable

const [enabled, setEnabled] = useShareableState('enabled').boolean().optional();
// enabled: boolean | null

date(defaultValue, options?) - Non-nullable

const [birthday, setBirthday] = useShareableState('birthday').date(new Date('1990-01-01'), {
  min: new Date('1900-01-01'),
  max: new Date(),
  action: 'replace',
});
// birthday: Date (never null)
// Format: YYYY-MM-DD (UTC)

date().optional(defaultValue?, options?) - Nullable

const [birthday, setBirthday] = useShareableState('birthday').date().optional();
// birthday: Date | null

enum<T>(allowedValues, defaultValue) - Non-nullable

type Theme = 'light' | 'dark' | 'auto';
const [theme, setTheme] = useShareableState('theme').enum<Theme>(
  ['light', 'dark', 'auto'],
  'light',
);
// theme: Theme (never null)

enum<T>().optional(allowedValues, defaultValue?) - Nullable

const [theme, setTheme] = useShareableState('theme')
  .enum<Theme>()
  .optional(['light', 'dark', 'auto']);
// theme: Theme | null

json<T>(defaultValue, options?) - Non-nullable

const [settings, setSettings] = useShareableState('settings').json<Settings>(
  { theme: 'light', lang: 'en' },
  {
    validate: (obj): obj is Settings => typeof obj === 'object' && 'theme' in obj,
    omitEmpty: (obj) => Object.keys(obj).length === 0,
    stringify: (obj) => JSON.stringify(obj, null, 0),
    parse: (str) => JSON.parse(str),
    action: 'replace',
  },
);
// settings: Settings (never null)

json<T>().optional(defaultValue?, options?) - Nullable

const [settings, setSettings] = useShareableState('settings').json<Settings>().optional();
// settings: Settings | null

custom<T>(defaultValue, parse, format) - Non-nullable

const [coords, setCoords] = useShareableState('pos').custom<[number, number]>(
  [0, 0],
  (str) => {
    const [x, y] = str.split(',').map(Number);
    return [x || 0, y || 0];
  },
  ([x, y]) => `${x},${y}`,
);
// coords: [number, number] (never null)

custom<T>().optional(defaultValue, parse, format) - Nullable

const [coords, setCoords] = useShareableState('pos')
  .custom<[number, number]>()
  .optional(
    null,
    (str) => {
      const [x, y] = str.split(',').map(Number);
      return [x || 0, y || 0];
    },
    (value) => (value === null ? '' : `${value[0]},${value[1]}`),
  );
// coords: [number, number] | null

🌐 SSR & Frameworks

Next.js

// pages/search.tsx or app/search/page.tsx
export default function SearchPage() {
  // ✅ Safe during SSR - returns default until hydration
  const [query, setQuery] = useShareableState('q').string('');

  return <SearchComponent query={query} onSearch={setQuery} />;
}

Remix

// routes/search.tsx
export default function SearchRoute() {
  const [filters, setFilters] = useShareableState('filters').json({});

  return <FilteredList filters={filters} onChange={setFilters} />;
}

🔧 Advanced Usage

Multiple Parameters

function useProductFilters() {
  return {
    search: useShareableState('q').string(''),
    category: useShareableState('cat').enum<'all' | 'new' | 'sale'>(['all', 'new', 'sale'], 'all'),
    priceRange: useShareableState('price').custom<[number, number]>(
      [0, 1000],
      (str) => str.split('-').map(Number) as [number, number],
      ([min, max]) => `${min}-${max}`,
    ),
    page: useShareableState('page').number(1, { min: 1 }),
  };
}

function ProductList() {
  const filters = useProductFilters();

  // All URL parameters are automatically synchronized
  // URL: ?q=laptop&cat=sale&price=100-500&page=2
}

Event Monitoring

useEffect(() => {
  const handleQueryChange = (event: CustomEvent) => {
    console.log('Query state changed:', event.detail);
    // { key: 'search', prev: '', next: 'react', source: 'set', ts: 1234567890 }
  };

  window.addEventListener('qs:changed', handleQueryChange);
  return () => window.removeEventListener('qs:changed', handleQueryChange);
}, []);

Reset to Defaults

function SearchFilters() {
  const [search, setSearch] = useShareableState('q').string('');
  const [category, setCategory] = useShareableState('cat').string('all');

  const clearFilters = () => {
    setSearch(''); // Removes ?q= from URL
    setCategory('all'); // Removes ?cat= from URL
  };

  return <button onClick={clearFilters}>Clear Filters</button>;
}

🚀 Migration Guide

From Manual URL Management

// Before: Manual URL state
const [search, setSearch] = useState('');

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  setSearch(params.get('q') || '');
}, []);

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  if (search) {
    params.set('q', search);
  } else {
    params.delete('q');
  }
  window.history.replaceState({}, '', `?${params}`);
}, [search]);

// After: useShareableState
const [search, setSearch] = useShareableState('q').string('');

From React Router useSearchParams

// Before: React Router
import { useSearchParams } from 'react-router-dom';

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') || '';

  const setQuery = (value: string) => {
    const newParams = new URLSearchParams(searchParams);
    if (value) {
      newParams.set('q', value);
    } else {
      newParams.delete('q');
    }
    setSearchParams(newParams);
  };
}

// After: useShareableState
function SearchPage() {
  const [query, setQuery] = useShareableState('q').string('');
}

From Next.js useRouter

// Before: Next.js useRouter
import { useRouter } from 'next/router';

function SearchPage() {
  const router = useRouter();
  const { q = '' } = router.query;

  const setQuery = (value: string) => {
    router.push(
      {
        pathname: router.pathname,
        query: { ...router.query, q: value || undefined },
      },
      undefined,
      { shallow: true },
    );
  };
}

// After: useShareableState
function SearchPage() {
  const [query, setQuery] = useShareableState('q').string('');
}

🤝 Contributing

We welcome contributions! Please see our Contributing Guide and Code of Conduct.

Development

# Install dependencies
pnpm install

# Run tests
pnpm test

# Build package
pnpm build

# Generate docs
pnpm docs:build

🏆 Used By

📄 License

MIT © Gawry & Co


Documentation · Examples · Issues · Discussions