JSPM

@gawryco/use-shareable-state

0.1.3
    • ESM via JSPM
    • ES Module Entrypoint
    • Export Map
    • Keywords
    • License
    • Repository URL
    • TypeScript Types
    • README
    • Created
    • Published
    • Downloads 9
    • Score
      100M100P100Q50377F
    • License MIT

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

    Package Exports

    • @gawryco/use-shareable-state

    Readme

    useShareableState

    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

    Join the companies using useShareableState in production:

    + Add your company

    📄 License

    MIT © Gawry & Co


    ⭐ Star us on GitHub — it motivates us a lot!

    Documentation · Examples · Issues · Discussions