examples: add 6 new over-engineering survivors + platform-native guide (#109)
New examples (examples/): - modal-dialog: <dialog> vs Radix/react-modal - url-params: URLSearchParams vs query-string - number-formatting: Intl.NumberFormat vs numeral - infinite-scroll: IntersectionObserver vs react-infinite-scroll-component - deep-clone: structuredClone vs lodash.cloneDeep / JSON hack - group-by: Object.groupBy vs lodash.groupBy New doc (docs/platform-native.md): Comprehensive reference of platform-native solutions across HTML elements, CSS, Browser APIs, Node.js stdlib, Python stdlib, and database features. Covers 60+ cases where the platform already has what developers reach for a package to do.
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
# Platform-Native Solutions
|
||||||
|
|
||||||
|
The lazy senior dev's first question is always: *does the platform already do this?*
|
||||||
|
|
||||||
|
This document answers that question for the most common cases. Before reaching for a package, scan here. The platform ships with your app for free, doesn't break on updates, and was written by people whose job is exactly that problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTML Elements
|
||||||
|
|
||||||
|
Things the browser already has as a form control.
|
||||||
|
|
||||||
|
| You think you need | What the platform has |
|
||||||
|
|---|---|
|
||||||
|
| Date picker library | `<input type="date">` |
|
||||||
|
| Time picker library | `<input type="time">` |
|
||||||
|
| Color picker library | `<input type="color">` |
|
||||||
|
| Range slider library | `<input type="range">` |
|
||||||
|
| Progress bar component | `<progress value="70" max="100">` |
|
||||||
|
| Meter/gauge component | `<meter value="0.7">` |
|
||||||
|
| Modal/dialog library | `<dialog>` + `dialog.showModal()` |
|
||||||
|
| Accordion/FAQ component | `<details><summary>Title</summary>…</details>` |
|
||||||
|
| Tooltip library | `title` attribute + CSS `::before`/`::after` |
|
||||||
|
| Searchable dropdown | `<input list="id"> <datalist id="id">` |
|
||||||
|
| Auto-growing textarea | `field-sizing: content` (CSS) |
|
||||||
|
| Sticky header | `position: sticky; top: 0` (CSS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Capabilities
|
||||||
|
|
||||||
|
Things developers reach for JavaScript to do.
|
||||||
|
|
||||||
|
| You think you need JS for | What CSS has |
|
||||||
|
|---|---|
|
||||||
|
| Responsive font size | `font-size: clamp(1rem, 2.5vw, 2rem)` |
|
||||||
|
| Fluid spacing | `padding: clamp(1rem, 5vw, 3rem)` |
|
||||||
|
| Dark mode | `@media (prefers-color-scheme: dark)` |
|
||||||
|
| Reduced motion | `@media (prefers-reduced-motion: reduce)` |
|
||||||
|
| Responsive layout without breakpoints | `grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))` |
|
||||||
|
| Component-level responsive design | `@container` queries |
|
||||||
|
| Global design tokens / theming | CSS custom properties (`--color-primary: #7c3aed`) |
|
||||||
|
| Smooth scroll | `scroll-behavior: smooth` |
|
||||||
|
| Scroll-snap carousel | `scroll-snap-type: x mandatory` + `scroll-snap-align: start` |
|
||||||
|
| Aspect ratio enforcement | `aspect-ratio: 16 / 9` |
|
||||||
|
| Truncate text with ellipsis | `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` |
|
||||||
|
| Multi-line text clamp | `-webkit-line-clamp: 3` |
|
||||||
|
| CSS cascade layers (style isolation) | `@layer base, components, utilities` |
|
||||||
|
| Nested CSS selectors | Native CSS nesting (no preprocessor needed) |
|
||||||
|
| `has()` parent selector | `:has(input:checked)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript / Browser APIs
|
||||||
|
|
||||||
|
Libraries people install that the runtime already ships.
|
||||||
|
|
||||||
|
| You think you need | What the platform has |
|
||||||
|
|---|---|
|
||||||
|
| `query-string` / `qs` | `new URLSearchParams(location.search)` |
|
||||||
|
| `lodash.clonedeep` | `structuredClone(obj)` |
|
||||||
|
| `lodash.groupby` | `Object.groupBy(arr, fn)` |
|
||||||
|
| `lodash.debounce` | — see debounce one-liner below |
|
||||||
|
| `numeral` / `accounting` | `new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })` |
|
||||||
|
| `date-fns` format | `new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(date)` |
|
||||||
|
| `date-fns` relative time | `new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-3, "day")` |
|
||||||
|
| `plural` / `i18n` plurals | `new Intl.PluralRules("en-US").select(count)` |
|
||||||
|
| `clipboard.js` | `navigator.clipboard.writeText(text)` |
|
||||||
|
| `uuid` (v4) | `crypto.randomUUID()` |
|
||||||
|
| Infinite scroll library | `new IntersectionObserver(cb).observe(sentinel)` |
|
||||||
|
| Resize listener library | `new ResizeObserver(cb).observe(element)` |
|
||||||
|
| DOM mutation watcher | `new MutationObserver(cb).observe(el, options)` |
|
||||||
|
| `uuid-validate` | `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)` |
|
||||||
|
| `is-online` / `connectivity check` | `navigator.onLine` + `online`/`offline` events |
|
||||||
|
| `sharesheet` library | `navigator.share({ title, text, url })` |
|
||||||
|
| `store.js` / `localForage` (simple case) | `localStorage.setItem(key, JSON.stringify(val))` |
|
||||||
|
| Abort fetch on timeout | `AbortSignal.timeout(5000)` passed to `fetch` |
|
||||||
|
| Custom event bus | `new EventTarget()` / `dispatchEvent(new CustomEvent("x", { detail }))` |
|
||||||
|
|
||||||
|
**Debounce one-liner** (no library):
|
||||||
|
```js
|
||||||
|
// ponytail: 3 lines beats a dependency
|
||||||
|
let t;
|
||||||
|
const debounce = (fn, ms) => (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js Standard Library
|
||||||
|
|
||||||
|
Packages that wrap Node built-ins.
|
||||||
|
|
||||||
|
| You think you need | What Node has |
|
||||||
|
|---|---|
|
||||||
|
| `mkdirp` | `fs.mkdirSync(path, { recursive: true })` |
|
||||||
|
| `rimraf` | `fs.rmSync(path, { recursive: true, force: true })` |
|
||||||
|
| `make-dir` | `fs.mkdirSync(path, { recursive: true })` |
|
||||||
|
| `slash` (win paths) | `path.posix` or `path.normalize()` |
|
||||||
|
| `uuid` (v4) | `crypto.randomUUID()` |
|
||||||
|
| `ms` (parse duration strings) | — keep `ms`, it's genuinely useful and tiny |
|
||||||
|
| `is-stream` | `val instanceof stream.Readable` |
|
||||||
|
| `object-assign` | `Object.assign()` / spread |
|
||||||
|
| `array-uniq` | `[...new Set(arr)]` |
|
||||||
|
| `array-flatten` | `arr.flat(Infinity)` |
|
||||||
|
| `flat` | `arr.flat(depth)` |
|
||||||
|
| `path-exists` | `fs.existsSync(path)` |
|
||||||
|
| `load-json-file` | `JSON.parse(fs.readFileSync(path, "utf8"))` |
|
||||||
|
| `write-json-file` | `fs.writeFileSync(path, JSON.stringify(obj, null, 2))` |
|
||||||
|
| `pkg-dir` | `path.resolve(__dirname, "..")` / `import.meta.dirname` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python Standard Library
|
||||||
|
|
||||||
|
Packages that wrap what Python already ships.
|
||||||
|
|
||||||
|
| You think you need | What Python has |
|
||||||
|
|---|---|
|
||||||
|
| `python-dateutil` (basic parsing) | `datetime.fromisoformat()` (Python 3.7+) |
|
||||||
|
| `pytz` | `zoneinfo.ZoneInfo("America/New_York")` (Python 3.9+) |
|
||||||
|
| `attrs` (simple data classes) | `@dataclass` |
|
||||||
|
| `six` | — drop it, Python 2 is gone |
|
||||||
|
| `pathlib2` | `pathlib.Path` (built-in since Python 3.4) |
|
||||||
|
| `enum34` | `enum.Enum` (built-in since Python 3.4) |
|
||||||
|
| `typing_extensions` (common types) | `from __future__ import annotations` + built-in generics |
|
||||||
|
| `simplejson` (basic use) | `json` (stdlib) |
|
||||||
|
| `requests` (simple GET) | `urllib.request.urlopen(url)` — `requests` for anything real |
|
||||||
|
| `click` (single command) | `argparse` (stdlib) |
|
||||||
|
| `mergedeep` | `dict \| other_dict` (Python 3.9+) |
|
||||||
|
| `more-itertools` (basic) | `itertools` (stdlib): `chain`, `islice`, `groupby`, `product` |
|
||||||
|
| `toolz` (basic) | `functools`: `lru_cache`, `partial`, `reduce` |
|
||||||
|
| `tabulate` (dev/debug only) | `pprint.pprint()` for quick inspection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Things the application layer implements that the database already does.
|
||||||
|
|
||||||
|
| You think you need app code for | What the database has |
|
||||||
|
|---|---|
|
||||||
|
| Pagination offset/limit | `LIMIT 20 OFFSET 40` |
|
||||||
|
| Running totals | `SUM(...) OVER (ORDER BY date)` (window function) |
|
||||||
|
| Rank within group | `RANK() OVER (PARTITION BY category ORDER BY score DESC)` |
|
||||||
|
| Pivot / cross-tab | `FILTER (WHERE ...)` + conditional aggregation |
|
||||||
|
| Deduplication | `SELECT DISTINCT` / `ON CONFLICT DO NOTHING` |
|
||||||
|
| Soft-delete filtering | Generated column + partial index |
|
||||||
|
| Tree traversal | Recursive CTE (`WITH RECURSIVE`) |
|
||||||
|
| Full-text search (basic) | `tsvector` / `MATCH AGAINST` / `FTS5` |
|
||||||
|
| JSON storage + query | `jsonb` (Postgres) / `JSON_EXTRACT` (SQLite/MySQL) |
|
||||||
|
| UUID generation | `gen_random_uuid()` (Postgres) / `UUID()` (MySQL) |
|
||||||
|
| Timestamps on insert/update | `DEFAULT now()` + trigger or `ON UPDATE CURRENT_TIMESTAMP` |
|
||||||
|
| Enforce uniqueness | `UNIQUE` constraint — not application-level checks |
|
||||||
|
| Enforce referential integrity | `FOREIGN KEY` — not application-level checks |
|
||||||
|
| Enforce value ranges | `CHECK (price > 0)` — not application-level validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Pattern
|
||||||
|
|
||||||
|
Across every layer, the pattern is the same:
|
||||||
|
|
||||||
|
```
|
||||||
|
Platform team spends years solving the problem.
|
||||||
|
Package author wraps it.
|
||||||
|
You install the wrapper.
|
||||||
|
The wrapper goes unmaintained.
|
||||||
|
You debug the wrapper.
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip the wrapper. The platform ships with your app for free.
|
||||||
|
|
||||||
|
When the native solution is genuinely insufficient (old browser support, edge cases it doesn't handle, ergonomics that matter at scale), the library earns its place. Install it then — not before.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Deep Clone
|
||||||
|
|
||||||
|
**Task:** "Deep clone this object."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
|
||||||
|
const copy = cloneDeep(original);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the classic hack:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// fragile: loses Date, undefined, Map, Set, circular refs, functions
|
||||||
|
const copy = JSON.parse(JSON.stringify(original));
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: structuredClone does this
|
||||||
|
const copy = structuredClone(original);
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency (or a fragile hack) → 1 built-in.** `structuredClone` handles `Date`, `Map`, `Set`, `ArrayBuffer`, `RegExp`, circular references, and more — everything `JSON.parse/stringify` silently drops. Available in every browser since 2022 and Node.js since v17. Pull lodash in when you need the rest of it, not for one function.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Group By
|
||||||
|
|
||||||
|
**Task:** "Group this array of objects by a key."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { groupBy } from "lodash";
|
||||||
|
|
||||||
|
const byStatus = groupBy(orders, "status");
|
||||||
|
// → { pending: [...], shipped: [...], delivered: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the hand-rolled version:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const byStatus = orders.reduce((acc, order) => {
|
||||||
|
(acc[order.status] ??= []).push(order);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: Object.groupBy does this
|
||||||
|
const byStatus = Object.groupBy(orders, order => order.status);
|
||||||
|
// → { pending: [...], shipped: [...], delivered: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency (or a reduce) → 1 built-in.** `Object.groupBy` shipped in Chrome 117, Firefox 119, Safari 17.4, Node.js 21. If you need a `Map` instead of a plain object: `Map.groupBy(orders, o => o.status)`. Check your target runtime; if you need IE11 or old Node, the `reduce` one-liner is still the right call — not lodash.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Infinite Scroll
|
||||||
|
|
||||||
|
**Task:** "Load more items when the user scrolls to the bottom."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-infinite-scroll-component
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
|
|
||||||
|
export function Feed({ items, fetchMore, hasMore }) {
|
||||||
|
return (
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={items.length}
|
||||||
|
next={fetchMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loader={<Spinner />}
|
||||||
|
endMessage={<p>No more items</p>}
|
||||||
|
scrollThreshold={0.9}
|
||||||
|
>
|
||||||
|
{items.map(item => <Card key={item.id} item={item} />)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dependency to watch scroll position and fire a callback.
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ponytail: IntersectionObserver does this, no scroll listener needed
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function Feed({ items, fetchMore, hasMore }) {
|
||||||
|
const sentinel = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting && hasMore) fetchMore();
|
||||||
|
});
|
||||||
|
if (sentinel.current) observer.observe(sentinel.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, fetchMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map(item => <Card key={item.id} item={item} />)}
|
||||||
|
<div ref={sentinel} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `IntersectionObserver` fires only when the sentinel enters the viewport — no scroll event, no throttling, no jank. Ships in every browser. The library wraps exactly this API.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Modal Dialog
|
||||||
|
|
||||||
|
**Task:** "Add a modal dialog for the delete confirmation."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @radix-ui/react-dialog
|
||||||
|
# or: npm install react-modal
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function DeleteModal({ onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button className="btn-danger">Delete</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
<Dialog.Content className="dialog-content">
|
||||||
|
<Dialog.Title>Confirm deletion</Dialog.Title>
|
||||||
|
<Dialog.Description>This action cannot be undone.</Dialog.Description>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button onClick={onCancel}>Cancel</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<button className="btn-danger" onClick={onConfirm}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dependency, a portal, an overlay, a root, a trigger, a content wrapper — to show a box with two buttons.
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ponytail: browser has one, with focus trapping and backdrop built in -->
|
||||||
|
<dialog id="confirm-delete">
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
<button id="cancel">Cancel</button>
|
||||||
|
<button id="confirm">Delete</button>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const dialog = document.getElementById("confirm-delete");
|
||||||
|
document.getElementById("cancel").onclick = () => dialog.close();
|
||||||
|
document.getElementById("confirm").onclick = () => { onConfirm(); dialog.close(); };
|
||||||
|
|
||||||
|
// Open it:
|
||||||
|
dialog.showModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency + 30 lines → 0 dependencies + 8 lines.** The native `<dialog>` traps focus automatically, closes on Escape, renders a backdrop via `::backdrop`, and is accessible by default. All browsers since 2022. The library was solving a problem the platform solved.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Number Formatting
|
||||||
|
|
||||||
|
**Task:** "Format numbers as currency and with thousand separators."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install numeral
|
||||||
|
# or: npm install accounting
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import numeral from "numeral";
|
||||||
|
|
||||||
|
numeral(1234567.89).format("$1,234.00"); // "$1,234,567.89"
|
||||||
|
numeral(0.745).format("0.0%"); // "74.5%"
|
||||||
|
numeral(1500).format("0.0a"); // "1.5k"
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: Intl.NumberFormat does this, locale-aware
|
||||||
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||||
|
.format(1234567.89);
|
||||||
|
// → "$1,234,567.89"
|
||||||
|
|
||||||
|
new Intl.NumberFormat("en-US", { style: "percent" })
|
||||||
|
.format(0.745);
|
||||||
|
// → "74.5%"
|
||||||
|
|
||||||
|
new Intl.NumberFormat("en-US", { notation: "compact" })
|
||||||
|
.format(1500);
|
||||||
|
// → "1.5K"
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `Intl.NumberFormat` is built into every JS runtime, handles every locale correctly, and gets currency symbols, decimal separators, and grouping right for any market without a lookup table. A library that hardcodes formats will always be wrong for someone.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# URL Parameters
|
||||||
|
|
||||||
|
**Task:** "Parse and build URL query strings."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install query-string
|
||||||
|
# 4.5 kB gzipped, 3.5M downloads/week
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import qs from "query-string";
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
const params = qs.parse(location.search);
|
||||||
|
// → { page: "2", sort: "name", tags: ["js", "css"] }
|
||||||
|
|
||||||
|
// Build
|
||||||
|
const url = qs.stringify({ page: 2, sort: "name", tags: ["js", "css"] });
|
||||||
|
// → "page=2&sort=name&tags=js&tags=css"
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: URLSearchParams does this
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
// Read
|
||||||
|
params.get("page"); // "2"
|
||||||
|
params.getAll("tags"); // ["js", "css"]
|
||||||
|
|
||||||
|
// Build
|
||||||
|
const out = new URLSearchParams({ page: 2, sort: "name" });
|
||||||
|
out.append("tags", "js");
|
||||||
|
out.append("tags", "css");
|
||||||
|
out.toString(); // "page=2&sort=name&tags=js&tags=css"
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `URLSearchParams` is in every browser and in Node.js since v10. It handles encoding, repeated keys, and iteration. The package was a polyfill for an API that has shipped everywhere for years.
|
||||||
Reference in New Issue
Block a user