From ba60ef8762a74f258bc819c2f80ed25237ea39d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 11 May 2026 01:00:31 +0200 Subject: [PATCH 1/7] docs: improve docs Co-Authored-By: Claude Opus 4.7 (1M context) --- readme.md | 4 +-- site/docs/cancellation.mdx | 10 +++---- site/docs/combining-stores.mdx | 6 ++-- site/docs/debugging.mdx | 14 ++++----- site/docs/dynamic-data.mdx | 8 +++--- site/docs/failures-retries.mdx | 24 ++++++++-------- site/docs/getting-started.mdx | 36 ++++++++++++------------ site/docs/infinite-scroll.mdx | 10 +++---- site/docs/invalidation.mdx | 8 +++--- site/docs/lazy-loading.mdx | 2 +- site/docs/lifetime.mdx | 2 +- site/docs/migrating-from-react-query.mdx | 26 ++++++++--------- site/docs/mutation-patterns.mdx | 6 ++-- site/docs/mutations.mdx | 21 +++++++------- site/docs/parallel.mdx | 4 +-- site/docs/performance.mdx | 28 +++++++++--------- site/docs/remote-data-pattern.mdx | 18 ++++++------ site/docs/shared-stores.mdx | 22 +++++++-------- site/docs/sharing-data-with-children.mdx | 8 +++--- site/docs/ssr.mdx | 18 ++++++------ site/docs/testing.mdx | 12 ++++---- site/docs/typed-errors.mdx | 18 ++++++------ site/src/pages/index.js | 30 ++++++++++---------- 23 files changed, 167 insertions(+), 168 deletions(-) diff --git a/readme.md b/readme.md index 8a468a0..55b9e28 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ Async data for React, without the guesswork. -Your data is always in exactly one state — loading, failed, or succeeded — and you +Your data is always in exactly one state (loading, failed, or succeeded), and you **cannot access the value without proving it exists**. No `T | undefined`. No boolean flags. No guessing. ```tsx @@ -13,7 +13,7 @@ const store = useRemoteData(() => fetchUser(id)); Inside the callback, `user` is `User`. Not `User | undefined`. TypeScript enforces it. -On top of this, you get automatic refresh, retry, composing multiple requests, mutations, lazy loading, and typed errors — all with zero dependencies beyond React. +On top of this, you get automatic refresh, retry, composing multiple requests, mutations, lazy loading, and typed errors, all with zero dependencies beyond React. **[Read the docs](https://oyvindberg.github.io/use-remote-data/)** diff --git a/site/docs/cancellation.mdx b/site/docs/cancellation.mdx index 0ed7f87..e888585 100644 --- a/site/docs/cancellation.mdx +++ b/site/docs/cancellation.mdx @@ -22,7 +22,7 @@ When `id` changes, the library: 2. **Discards** any response that arrives from the aborted request 3. **Starts** a fresh request with a new signal -If you don't use the signal, the HTTP request still completes in the background — but the response is discarded. +If you don't use the signal, the HTTP request still completes in the background, but the response is discarded. Forwarding the signal is optional but recommended: it saves bandwidth and frees server resources. ## When does cancellation happen? @@ -62,20 +62,20 @@ const saveStore = useRemoteUpdate((params, signal) => ); ``` -## Try it — type fast +## Try it: type fast This example simulates a search API with an 800ms delay. -Type quickly and watch the abort counter increase — only the final result renders. +Type quickly and watch the abort counter increase. Only the final result renders. ## Backward compatible If your fetch function doesn't accept a signal, everything still works. -The library discards stale responses internally via request versioning — +The library discards stale responses internally via request versioning; the signal just lets you cancel the actual HTTP request too. ```tsx -// This still works — signal is ignored, stale responses are still discarded +// This still works: signal is ignored, stale responses are still discarded useRemoteData(() => fetchUser(id), { dependencies: [id] }); ``` diff --git a/site/docs/combining-stores.mdx b/site/docs/combining-stores.mdx index 9165514..cbbbeb9 100644 --- a/site/docs/combining-stores.mdx +++ b/site/docs/combining-stores.mdx @@ -3,14 +3,14 @@ import typesafeCombine from '../static/typesafe-combine.webm'; # Combining Stores -One of the library’s most powerful patterns is combining multiple requests. +A core pattern in `use-remote-data` is combining multiple requests. If you have two or more `RemoteDataStore`s, you can merge them into a single store that represents all requests in flight. This is done via `RemoteDataStore.all(...)`: Under the hood, the combined store uses the `RemoteData.all(...)` function, which: -- Returns `Failed` if _any_ store fails. A single “retry” will only re-fetch the failing requests. +- Returns `Failed` if _any_ store fails. A single "retry" will only re-fetch the failing requests. - Returns `Pending` if _any_ constituent store is `Pending`. - Returns `Success` with a tuple of all combined values if _all_ succeed. - Manages stale states if any store becomes stale. @@ -21,7 +21,7 @@ the results. -#### A Note on TypeScript Tooling +#### A note on TypeScript tooling The TypeScript compiler (and IDEs) fully understands these combined stores. You can hover over the tuple items (often with Ctrl or Command) to see precise type information. diff --git a/site/docs/debugging.mdx b/site/docs/debugging.mdx index bf3405d..0e42142 100644 --- a/site/docs/debugging.mdx +++ b/site/docs/debugging.mdx @@ -1,11 +1,11 @@ # Debugging -## `storeName` — label your stores +## `storeName`: label your stores Every hook accepts an optional `storeName` string. It does two things: -1. **Error UI** — the default error component includes the name, so you can tell _which_ request failed. -2. **Debug logs** — when `debug` is enabled, every log line is prefixed with the store name. +1. **Error UI:** the default error component includes the name, so you can tell _which_ request failed. +2. **Debug logs:** when `debug` is enabled, every log line is prefixed with the store name. ```tsx const userStore = useRemoteData(() => fetchUser(id), { @@ -57,7 +57,7 @@ The `storeName` is passed to your error render prop via `ErrorProps`: ``` -## `debug` — trace state transitions +## `debug`: trace state transitions Pass `debug: console.warn` (or any function with the same signature) to see every state change in your console: @@ -113,11 +113,11 @@ console.log(store.current); // { type: 'success', value: { name: 'Alice' }, updatedAt: 2024-01-15T... } ``` -This is a plain object — you can `JSON.stringify` it, pass it to a logger, or inspect it in React DevTools. +This is a plain object: you can `JSON.stringify` it, pass it to a logger, or inspect it in React DevTools. The `type` field tells you exactly which state the store is in: `'initial'`, `'pending'`, `'failed'`, `'success'`, `'stale-immediate'`, `'stale-initial'`, or `'stale-pending'`. -## `` — visual panel +## ``: visual panel Drop the devtools component anywhere in your app to see all active stores at a glance: @@ -134,7 +134,7 @@ function App() { } ``` -The panel scans the React fiber tree and finds every `RemoteDataStore` passed as a prop to any component — no registration, no wrapper hooks, no provider. It shows: +The panel scans the React fiber tree and finds every `RemoteDataStore` passed as a prop to any component. No registration, no wrapper hooks, no provider. It shows: - Store name and current state (with color-coded indicators) - Data preview (truncated JSON for success, error message for failures) diff --git a/site/docs/dynamic-data.mdx b/site/docs/dynamic-data.mdx index 3fa1115..9f5f5f8 100644 --- a/site/docs/dynamic-data.mdx +++ b/site/docs/dynamic-data.mdx @@ -2,8 +2,8 @@ import { Snippet } from '../src/components/Snippet'; # Dynamic Data -If you need parameterized requests—like paginated lists -or fetching multiple resource IDs—use the **`useRemoteDataMap`** hook. +If you need parameterized requests (paginated lists, +or fetching multiple resource IDs), use the **`useRemoteDataMap`** hook. It returns an object with a `.get(key)` method for each distinct data slice. ```tsx @@ -15,8 +15,8 @@ const secondPageStore = itemsStore.get(2); This creates independent `RemoteDataStore` objects for each page, all managed under one `RemoteDataMap` instance. -If multiple components call `.get()` with the same key, they receive the same cached store — the fetch -only fires once. This makes `useRemoteDataMap` the natural way to share keyed data across a component +If multiple components call `.get()` with the same key, they receive the same cached store; the fetch +only fires once. This makes `useRemoteDataMap` a clean way to share keyed data across a component subtree without a global cache. See [Sharing Data with Children](sharing-data-with-children) for the pattern. diff --git a/site/docs/failures-retries.mdx b/site/docs/failures-retries.mdx index b7f10c6..59761d1 100644 --- a/site/docs/failures-retries.mdx +++ b/site/docs/failures-retries.mdx @@ -3,7 +3,7 @@ import { Snippet } from '../src/components/Snippet'; # Failures and Retries When a request fails, `use-remote-data` doesn't throw the error away or bury it in a boolean. -It moves the store to the **`Failed`** state — which holds the error _and_ a `retry` callback that re-runs the exact same request. +It moves the store to the **`Failed`** state, which holds the error _and_ a `retry` callback that re-runs the exact same request. ## How it works @@ -26,30 +26,30 @@ No `useState` for tracking error state. No `try/catch` boilerplate. No manual "r With `use-remote-data`, **there is never a reason to write `try/catch` for data fetching**. Errors don't throw. They don't propagate up the tree. They don't crash your app. -A failed request is just data — the `Failed` state — handled in the same place you render your success state. +A failed request is just data: the `Failed` state, handled in the same place you render your success state. This also means you don't need React error boundaries for data fetching errors. Error boundaries catch _thrown_ errors during rendering. Since `use-remote-data` never throws, -the `` component always renders cleanly — either your data callback, the pending view, or the error view. +the `` component always renders cleanly: either your data callback, the pending view, or the error view. There's nothing to catch. ## Retries with combined stores -This is where it gets powerful. When you combine multiple stores with `RemoteDataStore.all()`, +This matters most when you combine multiple stores with `RemoteDataStore.all()`, and _one_ of them fails: - The combined store moves to `Failed`. - The combined `retry` **only re-fetches the stores that failed**. The successful ones keep their data. - Once the failing store succeeds, the combined store moves to `Success` with the full tuple. -Try it — this example has a store that fails every 10th call, combined with a store that always succeeds. +Try it. This example has a store that fails every 10th call, combined with a store that always succeeds. Hit retry a few times to see how only the broken request re-fires. ## Customizing the error UI -The built-in error component is deliberately minimal — it's meant to be replaced. +The built-in error component is deliberately minimal; it's meant to be replaced. Pass your own `error` render prop to ``: ```tsx @@ -74,18 +74,18 @@ The `ErrorProps` type gives you: | `retry` | `() => Promise` | Re-runs the failed request | | `storeName` | `string \| undefined` | The `storeName` you passed to the hook (useful for debugging) | -For most apps, you only need `retry` — the `errors` array is there when you want to show specifics. +For most apps, you only need `retry`. The `errors` array is there when you want to show specifics. ### A note on the `errors` type `errors` is an array because [combined stores](#retries-with-combined-stores) can have multiple failures (one per failed request). For a single store, it's always a one-element array. -Each element is a `Failure` — a tagged object that is one of two things: +Each element is a `Failure`, a tagged object that is one of two things: -- `{ tag: 'unexpected', value: WeakError }` — an **unexpected error** (network failure, thrown exception, etc.). `WeakError` is just an alias for `Error | unknown`, because JavaScript lets you `throw` anything. -- `{ tag: 'expected', value: E }` — a **typed domain error** you returned explicitly (only when using the [typed errors](typed-errors) feature). +- `{ tag: 'unexpected', value: WeakError }`: an **unexpected error** (network failure, thrown exception, etc.). `WeakError` is just an alias for `Error | unknown`, because JavaScript lets you `throw` anything. +- `{ tag: 'expected', value: E }`: a **typed domain error** you returned explicitly (only when using the [typed errors](typed-errors) feature). -If you're not using typed errors, every error will be `unexpected` — so you can treat `failure.value` as the thrown value. Most of the time you won't inspect this at all; the `retry` callback is enough. +If you're not using typed errors, every error will be `unexpected`, so you can treat `failure.value` as the thrown value. Most of the time you won't inspect this at all; the `retry` callback is enough. ## Mutations fail the same way @@ -99,4 +99,4 @@ const saveStore = useRemoteUpdate((name: string) => api.save(name)); saveStore.run('Alice'); ``` -You can also call `reset()` to go back to `Initial` instead of retrying — useful when you'd rather let the user fix their input and try again. +You can also call `reset()` to go back to `Initial` instead of retrying. This is useful when you'd rather let the user fix their input and try again. diff --git a/site/docs/getting-started.mdx b/site/docs/getting-started.mdx index b598541..ed34fb6 100644 --- a/site/docs/getting-started.mdx +++ b/site/docs/getting-started.mdx @@ -25,20 +25,20 @@ return ; // data is User | null here. Oops. ``` Three `useState` calls. A `useEffect`. Manual flag management. -And at the end, TypeScript still thinks `data` might be `null` — because it can't see +And at the end, TypeScript still thinks `data` might be `null`. It can't see that you checked `loading` and `error` first. Libraries like `react-query` improve on this, but the core shape is the same: `data` is always `T | undefined`, -and you rely on convention — not types — to check before accessing it. +and you rely on convention (not types) to check before accessing it. **`use-remote-data` takes a different approach.** Instead of giving you optional data and boolean flags, it gives you a single state that is _always_ one of: not yet loaded, loading, failed (with retry), or success. You literally cannot access the data without first proving it exists. -This isn't just ergonomics. A forgotten null check is a bug that compiles. The difference between `T | undefined` and `T` +A forgotten null check is a bug that compiles. The difference between `T | undefined` and `T` is the difference between a bug you ship and a bug the compiler catches. -The idea is simple: your store is always in _one_ state — loading, failed, or success — and TypeScript +The idea is simple: your store is always in _one_ state (loading, failed, or success), and TypeScript won't let you access the data unless you're in the success state. If you use [zod](https://zod.dev/) for validation, [ts-pattern](https://github.com/gvergnaud/ts-pattern) for pattern matching, or [neverthrow](https://github.com/supermacro/neverthrow) for typed errors, @@ -61,7 +61,7 @@ yarn add use-remote-data ## Basic Usage The primary entry point is the **`useRemoteData`** hook. -Give it a function that returns a `Promise`, and you get back a **store** — an object that holds the current state of one async request (loading, failed, or success) and re-renders your component when that state changes: +Give it a function that returns a `Promise` and you get back a **store**: an object that holds the current state of one async request (loading, failed, or success) and re-renders your component when that state changes. @@ -79,21 +79,21 @@ function UserProfile({ id }: { id: string }) { That's it. The store handles fetching, caching, error states, and retries. -The `dependencies` array works like React's `useEffect` deps — when any value changes, the store re-fetches. +The `dependencies` array works like React's `useEffect` deps: when any value changes, the store re-fetches. Use it when your fetch depends on props or state that can change. Each element is compared with `Object.is`, so pass primitives (strings, numbers) rather than objects or arrays that get re-created every render. -## `` — render only when data is ready +## ``: render only when data is ready The `` component takes a store and a render callback. -Your callback **only runs when data is available**, and it receives the value typed as `T` — not `T | undefined`. +Your callback **only runs when data is available**, and it receives the value typed as `T`, not `T | undefined`. This is the key idea: you cannot accidentally render without data. TypeScript enforces it. The component structure enforces it. Loading and error states are handled for you by default. The `isStale` boolean (second argument) tells you if the data is stale and a background refresh is in progress. -This lets you grey out stale data instead of showing a spinner — much better UX. +This lets you grey out stale data instead of throwing the user back to a spinner. ### Customize the loading and error UI @@ -101,7 +101,7 @@ Out of the box, `` renders a minimal loading spinner and `JSON.stringify` You'll want to provide your own rendering. There are two ways: 1. **Pass `loading` and `error` render props** to `` directly. -2. **Create your own `` wrapper** — copy the component into your codebase and customize it. +2. **Create your own `` wrapper**. Copy the component into your codebase and customize it. It's small (about 15 lines). The only important thing is to keep the `useEffect(store.triggerUpdate)` call. --- @@ -117,13 +117,13 @@ Both react-query and SWR fetch data well. The difference is what happens after. | Stale data during refetch | Yes (`isFetching` / `isValidating`) | Yes (`isStale` flag) | | Retry on error | Automatic + manual `refetch()` | `retry()` callback in Failed state | | Combining requests | `useQueries` (typed arrays) | `RemoteDataStore.all()` (typed tuples) | -| Global cache | Yes — deduplication across components | Opt-in via [`SharedStoreProvider`](shared-stores) — local by default | +| Global cache | Yes, deduplication across components | Opt-in via [`SharedStoreProvider`](shared-stores); local by default | | Runtime dependencies | One (`@tanstack/query-core` / `swr`) | Zero | | Providers / context | `QueryClientProvider` / `SWRConfig` | None | | Performance | Per-query cache + observer overhead | [Faster at every sharing level](performance) | -| React Compiler | Supported | Supported — zero per-render allocations | +| React Compiler | Supported | Supported, with zero per-render allocations | -react-query and SWR are great libraries with large ecosystems. If you need a browser extension devtools, or automatic deduplication across components by default, they're solid choices. +react-query and SWR are great libraries with large ecosystems. If you need a browser-extension devtools panel or automatic cross-component deduplication by default, they're solid choices. `use-remote-data` is for when you want **airtight types** and a **simpler mental model**. No global cache to configure. No context providers. No optional data. @@ -131,15 +131,15 @@ Just a store, a component, and data that's always in an honest state. ### React Compiler and concurrent rendering -`use-remote-data` is compatible with the React Compiler and concurrent rendering features like `startTransition` and Suspense boundaries. All 51 components pass `react-compiler-healthcheck` with zero errors. +`use-remote-data` is compatible with the React Compiler and concurrent rendering features like `startTransition` and Suspense boundaries. All 54 components pass `react-compiler-healthcheck` with zero errors. -- All hooks follow the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) — no conditional or nested hook calls -- No ref writes during render — state is synced via `useEffect` / `useLayoutEffect` +- All hooks follow the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks): no conditional or nested hook calls +- No ref writes during render; state is synced via `useEffect` / `useLayoutEffect` - Refresh timers are managed via refs, surviving parent re-renders without resetting -- Zero per-render allocations — class instances are cached in refs, no closures recreated each render +- Zero per-render allocations: class instances are cached in refs, no closures recreated each render ### What about React 19's `use()` hook? React 19 introduced the `use()` hook for consuming promises during render. It integrates with Suspense boundaries for loading states and error boundaries for failures. -`use-remote-data` takes a different approach: instead of Suspense and error boundaries (which propagate up the tree), it handles loading and error states locally with ``. This gives you explicit control — you decide exactly what renders for each state, in the same component where you fetch. The two approaches can coexist: use `use()` for simple server-driven data, and `use-remote-data` when you want typed state machines, retry logic, refreshing, and composable stores. +`use-remote-data` takes a different approach: instead of Suspense and error boundaries (which propagate up the tree), it handles loading and error states locally with ``. This gives you explicit control: you decide exactly what renders for each state, in the same component where you fetch. The two approaches can coexist: use `use()` for simple server-driven data, and `use-remote-data` when you want typed state machines, retry logic, refreshing, and composable stores. diff --git a/site/docs/infinite-scroll.mdx b/site/docs/infinite-scroll.mdx index ea65915..a958cff 100644 --- a/site/docs/infinite-scroll.mdx +++ b/site/docs/infinite-scroll.mdx @@ -9,22 +9,22 @@ const [cursors, setCursors] = useState([0]); const pages = useRemoteDataMap((cursor, signal) => fetchPage(cursor, signal)); ``` -Each cursor maps to an independent store. Already-loaded pages stay rendered while the next page loads. If page 3 fails, pages 1 and 2 keep their data — the error and retry button only appear for page 3. +Each cursor maps to an independent store. Already-loaded pages stay rendered while the next page loads. If page 3 fails, pages 1 and 2 keep their data; the error and retry button only appear for page 3. ## Try it -This snippet simulates 25 items across 5 pages, with random failures on ~every 4th fetch. Click "Load more" to fetch the next page. When a page fails, click "Retry this page" — only the failed page re-fetches. +This snippet simulates 25 items across 5 pages, with random failures on roughly every 4th fetch. Click "Load more" to fetch the next page. When a page fails, click "Retry this page"; only the failed page re-fetches. ## How it works -1. `useState([0])` tracks which cursors to render — starts with the first page -2. `useRemoteDataMap` manages one store per cursor — fetches are deduped by key +1. `useState([0])` tracks which cursors to render; starts with the first page +2. `useRemoteDataMap` manages one store per cursor; fetches are deduped by key 3. Each `` independently handles loading, error, and success for its page 4. "Load more" appends the next cursor to the list, which triggers a new `` to mount and start fetching 5. A failed page shows its own error + retry without affecting the rest ## Compared to react-query -react-query has a dedicated `useInfiniteQuery` hook with built-in page management and cursor tracking. The `useRemoteDataMap` approach is simpler — no special hook, no `getNextPageParam` config — but you manage the cursor list yourself with `useState`. +react-query has a dedicated `useInfiniteQuery` hook with built-in page management and cursor tracking. The `useRemoteDataMap` approach is simpler (no special hook, no `getNextPageParam` config), but you manage the cursor list yourself with `useState`. diff --git a/site/docs/invalidation.mdx b/site/docs/invalidation.mdx index c5cdd1f..254c395 100644 --- a/site/docs/invalidation.mdx +++ b/site/docs/invalidation.mdx @@ -18,7 +18,7 @@ to decide whether to let the user see old data or a loading indicator. -### Only Sometimes? +### Only sometimes? You can enable or disable refreshing dynamically by swapping the `refresh` strategy in or out. For instance: @@ -26,14 +26,14 @@ You can enable or disable refreshing dynamically by swapping the `refresh` strat ## Poll until valid data -Something that comes up sometimes is APIs which require you to poll. +Some APIs require polling until they return final data. `use-remote-data` supports this through `RefreshStrategy.pollUntil` ## Refresh on Dependency Change -`useRemoteData` and `useRemoteDataMap` let you provide a `dependencies` array (similar to React’s `useEffect`): +`useRemoteData` and `useRemoteDataMap` let you provide a `dependencies` array (similar to React's `useEffect`): ```tsx useRemoteData(() => fetchData(), { @@ -42,7 +42,7 @@ useRemoteData(() => fetchData(), { ``` When the dependency array changes, the store refreshes automatically, and `triggerUpdate()` will re-fetch if needed. -Each element is compared using `Object.is` — the same semantics as React's `useEffect` dependencies. +Each element is compared using `Object.is`, the same semantics as React's `useEffect` dependencies. :::caution Pass primitives, not objects Since `Object.is` compares by reference, passing an object or array that gets re-created every render diff --git a/site/docs/lazy-loading.mdx b/site/docs/lazy-loading.mdx index 07ae99b..f6dd7e9 100644 --- a/site/docs/lazy-loading.mdx +++ b/site/docs/lazy-loading.mdx @@ -9,5 +9,5 @@ Only the stores that are actually rendered will trigger a network request. This means: - **No wasted requests.** A store behind a collapsed panel or an unselected tab stays idle. -- **No `enabled` flag.** You don't need to conditionally disable fetching — if the component isn't rendered, the fetch doesn't happen. +- **No `enabled` flag.** You don't need to conditionally disable fetching: if the component isn't rendered, the fetch doesn't happen. - **Declare everything upfront, fetch on demand.** Define your data dependencies at the top of your component tree, and let the rendering tree decide what's needed. diff --git a/site/docs/lifetime.mdx b/site/docs/lifetime.mdx index 213c988..b6b89ab 100644 --- a/site/docs/lifetime.mdx +++ b/site/docs/lifetime.mdx @@ -5,7 +5,7 @@ There is no global cache. No context provider. No cache keys to manage. Each store is created by a hook inside a component. When that component unmounts, the store is garbage-collected. Data exists exactly as long as the component that owns it. -This is simpler than a global cache — you never have stale entries haunting your app, you never need to manually +This is simpler than a global cache: you never have stale entries haunting your app, you never need to manually manage cache keys, and you never need to worry about data leaking between unrelated parts of your UI. The tradeoff: if two sibling components need the same data, you should create the store in their diff --git a/site/docs/migrating-from-react-query.mdx b/site/docs/migrating-from-react-query.mdx index 6ef8cf3..96d8c74 100644 --- a/site/docs/migrating-from-react-query.mdx +++ b/site/docs/migrating-from-react-query.mdx @@ -1,12 +1,12 @@ # Migrating from react-query -This guide maps react-query concepts to `use-remote-data` equivalents. The migration can be incremental — both libraries can coexist in the same app while you move queries over one at a time. +This guide maps react-query concepts to `use-remote-data` equivalents. The migration can be incremental: both libraries can coexist in the same app while you move queries over one at a time. ## The key difference -react-query gives you `data: T | undefined` and boolean flags (`isLoading`, `isError`, `isFetching`). You check the flags before accessing data, but TypeScript can't enforce this — a forgotten check is a bug that compiles. +react-query gives you `data: T | undefined` and boolean flags (`isLoading`, `isError`, `isFetching`). You check the flags before accessing data, but TypeScript can't enforce this; a forgotten check is a bug that compiles. -`use-remote-data` gives you a `RemoteDataStore` that is always in exactly one state. Inside ``, your data is `T` — not `T | undefined`. The type system enforces that you can't access data that doesn't exist. +`use-remote-data` gives you a `RemoteDataStore` that is always in exactly one state. Inside ``, your data is `T`, not `T | undefined`. The type system enforces that you can't access data that doesn't exist. ## Concept mapping @@ -16,14 +16,14 @@ react-query gives you `data: T | undefined` and boolean flags (`isLoading`, `isE | `useQuery({ queryKey, queryFn })` | `useSharedRemoteData(name, queryFn)` | For global deduplication. Or `useRemoteData(queryFn)` for component-scoped data. | | `queryKey: ['users', id]` | `name: 'users'` + `dependencies: [id]` | Shared stores use a string name for deduplication. Dependency changes trigger re-fetch. | | `data: T \| undefined` | `{(data: T) => ...}` | Data is `T` inside ``, never `undefined`. | -| `isLoading` | `store.current.type === 'pending'` | Or just use `` — it handles loading for you. | -| `isFetching` / `isValidating` | `isStale` (second arg in ``) | `{(data, isStale) => ...}` — stale data stays visible during background refresh. | +| `isLoading` | `store.current.type === 'pending'` | Or just use ``; it handles loading for you. | +| `isFetching` / `isValidating` | `isStale` (second arg in ``) | `{(data, isStale) => ...}`; stale data stays visible during background refresh. | | `error` | `Failed` state with `retry` callback | Errors are data, not exceptions. `` renders them automatically. | | `refetch()` | `store.refresh()` | Manual re-fetch. | | `gcTime` / `cacheTime` | `gcTime` option on `useSharedRemoteData` | Grace period before cleanup after last subscriber unmounts. | | `staleTime` | `RefreshStrategy.afterMillis(ms)` | How long data stays fresh before a background re-fetch. | -| `refetchInterval` | `RefreshStrategy.afterMillis(ms)` | Same mechanism — automatically re-fetches on schedule. | -| `enabled: false` | Don't render the `` | Stores are lazy — they only fetch when rendered. | +| `refetchInterval` | `RefreshStrategy.afterMillis(ms)` | Same mechanism, automatically re-fetches on schedule. | +| `enabled: false` | Don't render the `` | Stores are lazy; they only fetch when rendered. | | `select: (data) => data.name` | `store.map((data) => data.name)` | Transform the success value. | | `placeholderData` | `initial: RemoteData.success(placeholder)` | Seed the store with placeholder data. | | `useMutation` | `useRemoteUpdate` | First-class mutations with `run()`, `reset()`, typed state machine. | @@ -74,7 +74,7 @@ function UserProfile({ id }) { if (error) return

Error: {error.message}

; return

{user.name}

; - // user is User | undefined — TypeScript doesn't know + // user is User | undefined; TypeScript doesn't know // you checked isLoading and error first } ``` @@ -103,7 +103,7 @@ function UserProfile({ id }) { )} > {(user) =>

{user.name}

} - {/* user is User — always */} + {/* user is User. Always */}
); } @@ -143,12 +143,12 @@ Once all queries are migrated, remove `QueryClientProvider` and uninstall `@tans - **Type safety**: Data is `T` inside ``, not `T | undefined`. A forgotten null check is a compile error, not a runtime bug. - **Zero dependencies**: No `@tanstack/query-core`. Just React. - **Simpler model**: No query client, no cache configuration, no dehydration/hydration for SSR. Stores are values. -- **Testability**: `RemoteDataStore.always(RemoteData.success(data))` — no mock servers, no providers, no async coordination. +- **Testability**: `RemoteDataStore.always(RemoteData.success(data))`. No mock servers, no providers, no async coordination. - **Surgical retry**: When you combine stores and one fails, `retry()` only re-fetches the broken one. ## What you give up -- **Devtools**: react-query's devtools is a browser extension with richer filtering and timeline views. `use-remote-data`'s `` is an in-app panel — simpler but no install required. +- **Devtools**: react-query's devtools is a browser extension with richer filtering and timeline views. `use-remote-data`'s `` is a smaller in-app panel that needs no extension install. - **Automatic deduplication by default**: `useRemoteData` is component-scoped by default. You opt into deduplication by [passing stores as props](sharing-data-with-children) or with `useSharedRemoteData`. - **Optimistic updates**: Not built-in. You can implement them by updating local state before the mutation completes. @@ -195,7 +195,7 @@ function PostFeed() { } ``` -Each page is an independent store entry — already-loaded pages stay rendered while the next page loads. See the [Infinite Scroll](infinite-scroll) page for a runnable example. +Each page is an independent store entry, so already-loaded pages stay rendered while the next page loads. See the [Infinite Scroll](infinite-scroll) page for a runnable example. ## Coexistence @@ -206,4 +206,4 @@ Both libraries can coexist. A common migration pattern: 3. When you touch a file for other reasons, migrate its queries 4. Eventually remove react-query -The two libraries don't share state, so a query in react-query and a shared store in `use-remote-data` with the same data will fetch independently. This is fine during migration — just don't have both active for the same data in the same component. +The two libraries don't share state, so a query in react-query and a shared store in `use-remote-data` with the same data will fetch independently. This is fine during migration. Avoid having both active for the same data in the same component. diff --git a/site/docs/mutation-patterns.mdx b/site/docs/mutation-patterns.mdx index 626913e..f3f2dbd 100644 --- a/site/docs/mutation-patterns.mdx +++ b/site/docs/mutation-patterns.mdx @@ -16,7 +16,7 @@ const saveStore = useRemoteUpdate((params: { name: string }) => api.createUser(p }, }); -// In your JSX — just wire run() to the button: +// In your JSX, wire run() to the button: ; @@ -26,7 +26,7 @@ No `useState`, no conditional mounting, no side effects in render callbacks. ## Show the result -Sometimes the mutation returns data you want to display — like a dry-run preview or a confirmation. +Sometimes the mutation returns data you want to display, like a dry-run preview or a confirmation. ```tsx const previewStore = useRemoteUpdate( @@ -44,7 +44,7 @@ const previewStore = useRemoteUpdate( ``` -Re-running while showing the previous result gives you the `StalePending` state automatically — +Re-running while showing the previous result gives you the `StalePending` state automatically: old data stays visible while the new request is in flight. ## Interplay with read stores diff --git a/site/docs/mutations.mdx b/site/docs/mutations.mdx index 0c0f955..f6c883d 100644 --- a/site/docs/mutations.mdx +++ b/site/docs/mutations.mdx @@ -2,24 +2,23 @@ import { Snippet } from '../src/components/Snippet'; # Mutations -Most applications don't just read data — they also **write** it. -`use-remote-data` now has first-class support for mutations through **`useRemoteUpdate`**, -giving you the same principled, type-safe experience for writes that `useRemoteData` provides for reads. +Most applications don't just read data; they also **write** it. +`useRemoteUpdate` gives you the same typed state machine for writes that `useRemoteData` provides for reads. ## The basics The **`useRemoteUpdate`** hook takes a function that performs a write operation and returns a `RemoteUpdateStore`. -Unlike `useRemoteData`, **nothing happens until you call `run()`** — the mutation is entirely imperative. +Unlike `useRemoteData`, **nothing happens until you call `run()`**. The mutation is entirely imperative. The store starts in an `Initial` state. When `run()` is called, it transitions to `Pending`, then to `Success` (success) -or `Failed` (failure). The entire `RemoteData` state machine you already know applies here too! +or `Failed` (failure). The same `RemoteData` state machine you already know applies here. ## Parameters The function you pass to `useRemoteUpdate` can accept parameters. -Pass them to `run()` at call-time — no `dependencies` array needed. +Pass them to `run()` at call-time. No `dependencies` array needed. @@ -29,7 +28,7 @@ form values are passed when the user clicks submit, not captured in a closure th ## Automatic refresh After a successful mutation, you usually need to refresh some read stores. -The `refreshes` option does this for you — just pass the stores that should refresh: +The `refreshes` option does this for you. Pass the stores that should refresh: @@ -39,7 +38,7 @@ Any `Await` rendering those stores will re-fetch. ## Success and error callbacks For fire-and-forget patterns like dialogs, you can use `onSuccess` to perform side effects -like closing a dialog — without putting imperative code in your render functions: +(like closing a dialog) without putting imperative code in your render functions: ```tsx const store = useRemoteUpdate((params) => saveItem(params), { @@ -54,7 +53,7 @@ The callback runs once, after the refresh completes. ## Resetting Call `reset()` to return the store to its `Initial` state. -This is perfect for dialogs — reset when closing so the next open starts fresh: +Useful for dialogs: reset when closing so the next open starts fresh. ```tsx const handleClose = () => { @@ -69,7 +68,7 @@ or after success to clear the result. ## Rendering with `AwaitUpdate` For cases where you want to render the mutation result, use `AwaitUpdate`. -It works like `Await` but **never auto-triggers** — fetching only starts when `run()` is called. +It works like `Await` but **never auto-triggers**. Fetching only starts when `run()` is called. The children receive the data, the `run` function (for re-runs), and `reset`: @@ -79,5 +78,5 @@ The optional `idle` prop controls what renders before the mutation has ever been ## Retries -If a mutation fails, the default error view automatically shows a retry button — just like read stores. +If a mutation fails, the default error view automatically shows a retry button, just like read stores. Retry re-runs with the **same parameters** that were originally passed. diff --git a/site/docs/parallel.mdx b/site/docs/parallel.mdx index 3e6291d..4ad197e 100644 --- a/site/docs/parallel.mdx +++ b/site/docs/parallel.mdx @@ -1,7 +1,7 @@ # Parallel vs. Sequential Fetching When multiple `` components render at the same time, their stores fire requests **in parallel** automatically. -No configuration needed — if two stores both need data, both requests go out at once. +No configuration needed: if two stores both need data, both requests go out at once. Need sequential fetching? Nest your `` components: @@ -13,5 +13,5 @@ Need sequential fetching? Nest your `` components: The inner `` won't render (or fetch) until the outer one has data. -For the common case of "fetch both, wait for both," use `RemoteDataStore.all()` instead — it's +For the common case of "fetch both, wait for both," use `RemoteDataStore.all()` instead. It's parallel, type-safe, and gives you a single `` with a tuple of results. See [Combining Stores](combining-stores). diff --git a/site/docs/performance.mdx b/site/docs/performance.mdx index ce5e362..2c3d5c4 100644 --- a/site/docs/performance.mdx +++ b/site/docs/performance.mdx @@ -6,19 +6,19 @@ 1,000 components, each fetching a number with 5ms simulated latency, rendering "..." while loading, then the resolved value. Measured in Chrome using `performance.now()`, median of 5 iterations. Three metrics: -- **Mount** — render all components from scratch -- **Re-render** — parent state change forces all children to re-render (data already loaded) -- **Full lifecycle** — mount, fetch, resolve, render data (the metric that matters most) +- **Mount:** render all components from scratch +- **Re-render:** parent state change forces all children to re-render (data already loaded) +- **Full lifecycle:** mount, fetch, resolve, render data (the metric that matters most) Each library uses its recommended pattern: -- **use-remote-data** — `useRemoteDataMap` in a parent component, `` for each item. The parent owns the state and renders the items directly. When multiple items share the same key, only one fetch fires — deduplication comes free from the data structure, not from a cache layer. -- **react-query** — `useQuery` per component. Deduplication happens via the QueryClient cache. -- **raw React** — `useState` + `useEffect` per component. No deduplication. +- **use-remote-data:** `useRemoteDataMap` in a parent component, `` for each item. The parent owns the state and renders the items directly. When multiple items share the same key, only one fetch fires; deduplication is a property of the data structure, not a cache layer. +- **react-query:** `useQuery` per component. Deduplication happens via the QueryClient cache. +- **raw React:** `useState` + `useEffect` per component. No deduplication. ## Results -### Full lifecycle (ms) — 1,000 components +### Full lifecycle, 1,000 components (ms) | Resources | Each fetched by | raw React | **use-remote-data** | react-query | | --------: | --------------: | --------: | ------------------: | ----------: | @@ -30,7 +30,7 @@ Each library uses its recommended pattern: `use-remote-data` is faster than react-query at every sharing level. It's also faster than raw React as soon as any data is shared (2+ components per resource), because `useRemoteDataMap` deduplicates fetches while raw React fires one fetch per component regardless. -### Mount and re-render (ms) — 1,000 unique resources +### Mount and re-render, 1,000 unique resources (ms) | Scenario | Mount | Re-render | | ------------------- | ----: | --------: | @@ -38,7 +38,7 @@ Each library uses its recommended pattern: | **use-remote-data** | 20 | 7 | | **react-query** | 45 | 16 | -Mount and re-render overhead is minimal for `use-remote-data` — within noise of raw React. react-query adds ~2x overhead on mount due to QueryObserver creation and cache subscription. +Mount and re-render overhead for `use-remote-data` is indistinguishable from raw React. react-query adds ~2x overhead on mount, mostly QueryObserver creation and cache subscription. ## How sharing works @@ -50,7 +50,7 @@ function Dashboard() { return (
- {/* All three share one store — one fetch, one state */} + {/* All three share one store: one fetch, one state */} @@ -81,19 +81,19 @@ No provider, no query keys, no cache configuration. Just React components passin ## Why it's fast -`use-remote-data` stores state in React's own `useState` with class instances allocated once via `useRef`. The per-render cost is a single `sync()` call (three field assignments). Store objects are cached — same reference across renders, no allocations. +`use-remote-data` stores state in React's own `useState` with class instances allocated once via `useRef`. The per-render cost is a single `sync()` call (three field assignments). Store objects are cached, so they keep the same reference across renders with no allocations. -react-query is built on a `QueryClient` cache with `QueryObserver` instances that subscribe via `useSyncExternalStore`. Every query creates an observer, hashes its key, subscribes to cache notifications, and runs through retry/focus/reconnect middleware. These features buy you devtools and automatic refetching — but they have a per-query cost that adds up. +react-query is built on a `QueryClient` cache with `QueryObserver` instances that subscribe via `useSyncExternalStore`. Every query creates an observer, hashes its key, subscribes to cache notifications, and runs through retry/focus/reconnect middleware. These features buy you devtools and automatic refetching, but they have a per-query cost that adds up. ## The one tradeoff -With 1,000 unique resources (no sharing), `use-remote-data` (102ms) is ~2x slower than raw React (53ms). This is because `useRemoteDataMap` holds all state in one `useState` — when any key resolves, the parent re-renders all children. Raw React has per-component state, so only the resolving component re-renders. +With 1,000 unique resources (no sharing), `use-remote-data` (102ms) is ~2x slower than raw React (53ms). The reason: `useRemoteDataMap` holds all state in one `useState`, so when any key resolves the parent re-renders all children. Raw React has per-component state, so only the resolving component re-renders. This tradeoff disappears as soon as any sharing exists: at just 2 components per resource, `use-remote-data` is already faster than raw React (167ms vs 191ms) because deduplication saves more time than cascading re-renders cost. ## Reproducing -The benchmark app is in `bench/`. It runs in a real browser — no jsdom. +The benchmark app is in `bench/`. It runs in a real browser, not jsdom. ```bash cd bench diff --git a/site/docs/remote-data-pattern.mdx b/site/docs/remote-data-pattern.mdx index d449a90..4b5055c 100644 --- a/site/docs/remote-data-pattern.mdx +++ b/site/docs/remote-data-pattern.mdx @@ -1,7 +1,7 @@ # The RemoteData Type Every async request is in exactly one state: it hasn't started, it's in flight, it failed, or it succeeded. -Most code models this with a bag of booleans and optional values — `isLoading`, `data?`, `error?` — and nothing stops you from having `isLoading: true` and `data: someValue` at the same time. +Most code models this with a bag of booleans and optional values (`isLoading`, `data?`, `error?`), and nothing stops you from having `isLoading: true` and `data: someValue` at the same time. `RemoteData` is a union type. One state at a time. TypeScript knows which one. @@ -9,8 +9,8 @@ Most code models this with a bag of booleans and optional values — `isLoading` type RemoteData = | { type: 'initial' } // haven't started yet | { type: 'pending' } // request in flight - | { type: 'failed'; errors; retry } // failed — with errors and a retry function - | { type: 'success'; value: T }; // succeeded — here's your data + | { type: 'failed'; errors; retry } // failed, with errors and a retry function + | { type: 'success'; value: T }; // succeeded, here's your data ``` When you're in the `'success'` branch, `value` is `T`. Not `T | undefined`. Not `T | null`. Just `T`. @@ -18,7 +18,7 @@ No narrowing gymnastics. TypeScript does it for you. ## Stale data is still data -Most libraries throw your data away when they re-fetch. You had a dashboard full of numbers — now you have a spinner. +Most libraries throw your data away when they re-fetch. You had a dashboard full of numbers; now you have a spinner. `use-remote-data` keeps the old value visible while the refresh happens. Three additional states handle this: @@ -28,7 +28,7 @@ Most libraries throw your data away when they re-fetch. You had a dashboard full | `stale-initial` | Previously-fresh data was marked stale, re-fetch hasn't started yet | Yes | | `stale-pending` | Re-fetch is in progress, old data still visible | Yes | -**You don't need to memorize these.** Inside ``, all three are collapsed into a single boolean — `isStale` — which tells you everything: +**You don't need to memorize these.** Inside ``, all three are collapsed into a single boolean, `isStale`, which tells you everything: ```tsx @@ -45,7 +45,7 @@ Data stays on screen. Users see something useful instead of a spinner. One boole ## Exhaustive matching Most of the time you'll use `` and never touch these states directly. -But when you need custom control flow — logging, analytics, conditional rendering — you +But when you need custom control flow (logging, analytics, conditional rendering), you can switch on the `type` field, and TypeScript will enforce that you handle every case: ```ts @@ -65,7 +65,7 @@ function describe(rd: RemoteData): string { return `Stale (waiting): ${rd.stale.value}`; case 'stale-pending': return `Refreshing: ${rd.stale.value}`; - // No default — TypeScript errors if you miss a case. + // No default; TypeScript errors if you miss a case. } } ``` @@ -90,8 +90,8 @@ const label = match(store.current) The `RemoteData` pattern originates in [Elm](https://package.elm-lang.org/packages/krisajenkins/remotedata/latest/) and has been adopted across many typed languages. -The core idea — represent async state as a union type with a `type` tag, instead of a bag of booleans — is the same +The core idea (represent async state as a union type with a `type` tag, instead of a bag of booleans) is the same principle behind [zod](https://zod.dev/) (parse, don't validate), [neverthrow](https://github.com/supermacro/neverthrow) (errors as values), and [ts-pattern](https://github.com/gvergnaud/ts-pattern) (exhaustive matching). -If you like TypeScript's tagged unions (sometimes called "discriminated unions"), `RemoteData` is the natural way to model async data. +If you like TypeScript's tagged unions (sometimes called "discriminated unions"), `RemoteData` should feel familiar. diff --git a/site/docs/shared-stores.mdx b/site/docs/shared-stores.mdx index 1081437..9b7e9b1 100644 --- a/site/docs/shared-stores.mdx +++ b/site/docs/shared-stores.mdx @@ -2,9 +2,9 @@ import { Snippet } from '../src/components/Snippet'; # Shared Stores -By default, stores live with the component that creates them. This is the right default — no stale cache, no global state to manage, simple data lifetime. When multiple components need the same data, the recommended approach is to [lift the store up and pass it down](sharing-data-with-children). +By default, stores live with the component that creates them. This is the right default: no stale cache, no global state to manage, simple data lifetime. When multiple components need the same data, the recommended approach is to [lift the store up and pass it down](sharing-data-with-children). -**`useSharedRemoteData`** is for cases where prop-passing isn't practical — typically when deeply nested or unrelated components need the same data without a common parent in reach. It uses name-based deduplication: two components that call `useSharedRemoteData("currentUser", fetchUser)` share the same store. One fetch, shared state, no prop-drilling. +**`useSharedRemoteData`** is for cases where prop-passing isn't practical, typically when deeply nested or unrelated components need the same data without a common parent in reach. It uses name-based deduplication: two components that call `useSharedRemoteData("currentUser", fetchUser)` share the same store. One fetch, shared state, no prop-drilling. This is also useful as a **migration path from react-query**, where global deduplication via query keys is the default pattern. `useSharedRemoteData` offers the same ergonomics. See [Migrating from react-query](migrating-from-react-query) for a step-by-step guide. @@ -41,7 +41,7 @@ function SettingsPanel() { } ``` -The first component to mount with a given name starts the fetch. Subsequent components with the same name immediately receive the current state — no duplicate request. +The first component to mount with a given name starts the fetch. Subsequent components with the same name immediately receive the current state, with no duplicate request. ### Try it @@ -51,13 +51,13 @@ The first component to mount with a given name starts the fetch. Subsequent comp - The **name** is the cache key. Same name = same store, same data. - The **first registrant's fetcher wins**. If two components register `"currentUser"` with different fetcher functions, the first one is used and a dev-mode warning is logged. -- When the **last subscriber unmounts**, the entry is cleaned up — in-flight requests aborted, refresh timers cancelled, state dropped. +- When the **last subscriber unmounts**, the entry is cleaned up: in-flight requests aborted, refresh timers cancelled, state dropped. :::tip Define fetchers outside components Since the first registrant's fetcher wins, avoid defining fetchers inline (which creates a new reference each render). Instead, define them outside the component or wrap them in `useCallback`: ```tsx -// Good — stable reference +// Good: stable reference const fetchUser = (signal: AbortSignal) => fetch('/api/me', { signal }).then((r) => r.json()); function NavBar() { @@ -68,7 +68,7 @@ function NavBar() { ::: -## `gcTime` — grace period before cleanup +## `gcTime`: grace period before cleanup By default, when the last subscriber unmounts, the entry is immediately deleted. This can cause unnecessary re-fetches during route transitions where a component unmounts and remounts quickly. @@ -96,7 +96,7 @@ const store = useSharedRemoteData('prices', fetchPrices, { One timer per entry, not per subscriber. When the refresh fires, all subscribers see the new data. -## `refresh()` — manual re-fetch +## `refresh()`: manual re-fetch Calling `store.refresh()` from any subscriber re-fetches for all of them: @@ -109,7 +109,7 @@ function RefreshButton() { ## SSR with `initialData` -Pass server-rendered data to the provider. Stores with matching names start in `Success` — no loading spinner, no hydration mismatch: +Pass server-rendered data to the provider. Stores with matching names start in `Success`, so there's no loading spinner and no hydration mismatch: ```tsx // Server component fetches data @@ -146,12 +146,12 @@ const userOrNull = sharedUser.orNull; | | `useRemoteData` | `useSharedRemoteData` | | -------------------- | -------------------------------------- | -------------------------------------------- | | Store lifetime | Component that called the hook | While any subscriber is mounted (+ `gcTime`) | -| Deduplication | None — each call creates its own store | By name — same name = same store | +| Deduplication | None; each call creates its own store | By name; same name = same store | | Requires provider | No | Yes (``) | -| Prop-drilling needed | Yes, for sharing | No — same name = same store | +| Prop-drilling needed | Yes, for sharing | No; same name = same store | | Best for | Data owned by one component tree | Data shared across unrelated components | -**Start with `useRemoteData`** and pass stores as props — it's simpler, has no provider, no cache to configure, and is [faster at every sharing level](performance). Reach for `useSharedRemoteData` when prop-passing truly isn't practical (deeply nested trees, unrelated component subtrees), or as a migration bridge from react-query. +**Start with `useRemoteData`** and pass stores as props. It's simpler, has no provider, no cache to configure, and is [faster at every sharing level](performance). Reach for `useSharedRemoteData` when prop-passing truly isn't practical (deeply nested trees, unrelated component subtrees), or as a migration bridge from react-query. ## Options diff --git a/site/docs/sharing-data-with-children.mdx b/site/docs/sharing-data-with-children.mdx index 837c5f7..b9863dc 100644 --- a/site/docs/sharing-data-with-children.mdx +++ b/site/docs/sharing-data-with-children.mdx @@ -4,7 +4,7 @@ import { Snippet } from '../src/components/Snippet'; A common pattern: fetch data in a parent and share it across child components or routes. -Because stores are **lazy** and **caching**, this just works. Pass the same store to multiple children — +Because stores are **lazy** and **caching**, this just works. Pass the same store to multiple children: the request fires once, and every `` that renders it gets the same cached result. @@ -30,7 +30,7 @@ function OrderList({ orderIds }: { orderIds: number[] }) { } ``` -If multiple children call `orders.get(5)`, they all receive the same cached store — one fetch, shared state. +If multiple children call `orders.get(5)`, they all receive the same cached store: one fetch, shared state. This is automatic deduplication without a global cache, query keys, or providers. Just React's normal data flow. See the [Performance](performance) page for benchmarks showing this pattern is faster than react-query @@ -40,5 +40,5 @@ at every sharing level. Ask yourself: does the child need to show its own loading state? -- **Yes** — pass `RemoteDataStore`. The child renders `` and handles loading/error itself. -- **No** — the parent already handles loading via ``, so just pass the unwrapped `T` value. +- **Yes:** pass `RemoteDataStore`. The child renders `` and handles loading/error itself. +- **No:** the parent already handles loading via ``, so just pass the unwrapped `T` value. diff --git a/site/docs/ssr.mdx b/site/docs/ssr.mdx index 7aecbe3..3bd76d8 100644 --- a/site/docs/ssr.mdx +++ b/site/docs/ssr.mdx @@ -3,7 +3,7 @@ ## The component doesn't care A component that takes a `RemoteDataStore` has no idea where the data came from. -Client fetch, server fetch, hardcoded test data — it doesn't matter. It just renders: +Client fetch, server fetch, hardcoded test data: it doesn't matter. It just renders: ```tsx function ProjectCard({ store }: { store: RemoteDataStore }) { @@ -16,7 +16,7 @@ SSR is just: **someone filled the store before you got here.** ## How to fill a store with server data -A store is a state machine. It can start in any state — not just `Initial`. +A store is a state machine. It can start in any state, not just `Initial`. Pass the `initial` option to start it with data you already have: ```tsx @@ -26,14 +26,14 @@ const store = useRemoteData(() => fetchProjects(), { ``` `` sees `Success` on the very first render. No loading spinner. No hydration mismatch. -After hydration, the store works as usual — refreshing, re-fetching, mutations, retries all kick in. +After hydration, the store works as usual: refreshing, re-fetching, mutations, retries all kick in. ## Next.js App Router example The server component fetches data and passes it as a plain prop. The client component initializes the store with it. -```tsx title="app/dashboard/page.tsx — Server Component" +```tsx title="app/dashboard/page.tsx (Server Component)" import { ProjectList } from './ProjectList'; export default async function DashboardPage() { @@ -42,7 +42,7 @@ export default async function DashboardPage() { } ``` -```tsx title="app/dashboard/ProjectList.tsx — Client Component" +```tsx title="app/dashboard/ProjectList.tsx (Client Component)" 'use client'; import { Await, RefreshStrategy, RemoteData, useRemoteData } from 'use-remote-data'; @@ -69,10 +69,10 @@ export function ProjectList({ serverData }: { serverData: Project[] }) { ### What happens -1. **Server** — `page.tsx` awaits the query, renders `` -2. **SSR** — `useState` initializes in `Success`. `` renders the list. `useEffect` is skipped (never runs during SSR). The HTML already contains the project list. -3. **Hydration** — `useEffect(store.triggerUpdate)` fires. Store is `Success`, so the refresh strategy schedules a background re-fetch in 30 seconds. -4. **After 30s** — store refreshes in the background, `isRefreshing` goes `true`, new data arrives. +1. **Server:** `page.tsx` awaits the query, renders `` +2. **SSR:** `useState` initializes in `Success`. `` renders the list. `useEffect` is skipped (never runs during SSR). The HTML already contains the project list. +3. **Hydration:** `useEffect(store.triggerUpdate)` fires. Store is `Success`, so the refresh strategy schedules a background re-fetch in 30 seconds. +4. **After 30s:** store refreshes in the background, `isRefreshing` goes `true`, new data arrives. ## Why no ``? diff --git a/site/docs/testing.mdx b/site/docs/testing.mdx index 6a7e84c..a24c618 100644 --- a/site/docs/testing.mdx +++ b/site/docs/testing.mdx @@ -2,13 +2,13 @@ ## The advantage of values -Most data-fetching libraries require mocking to test. You mock the HTTP layer, or wrap your component in a provider, or stub a global cache — and your test is more about the library's internals than your component's behavior. +Most data-fetching libraries require mocking to test. You mock the HTTP layer, or wrap your component in a provider, or stub a global cache, and your test is more about the library's internals than your component's behavior. `use-remote-data` doesn't have this problem. A component that takes a `RemoteDataStore` doesn't know or care where the data came from. It's just a value, passed as a parameter. You construct the value, pass it in, and assert what renders. No mocking. No providers. No test utilities. Just data. -## `RemoteDataStore.always()` — a store in any state +## `RemoteDataStore.always()`: a store in any state The key function for testing is `RemoteDataStore.always()`. It creates a store that's permanently in whatever state you give it: @@ -25,7 +25,7 @@ const loading = RemoteDataStore.always(RemoteData.Pending); const idle = RemoteDataStore.always(RemoteData.Initial); ``` -That's it. These are real `RemoteDataStore` instances — your components can't tell the difference between these and a store created by `useRemoteData`. They render the same way, with the same types. +That's it. These are real `RemoteDataStore` instances; your components can't tell the difference between these and a store created by `useRemoteData`. They render the same way, with the same types. ## Testing a component @@ -94,7 +94,7 @@ test('dashboard renders when all data is loaded', () => { test('dashboard shows error when one request fails', () => { const userStore = RemoteDataStore.always(RemoteData.success({ name: 'Alice' })); - // Failure.unexpected() wraps a crash — see "Failures and Retries" for details + // Failure.unexpected() wraps a crash; see "Failures and Retries" for details const postsStore = RemoteDataStore.always( RemoteData.Failed([Failure.unexpected(new Error('timeout'))], async () => {}) ); @@ -106,7 +106,7 @@ test('dashboard shows error when one request fails', () => { }); ``` -The combined store computes its state from the constituent stores immediately — no async coordination. +The combined store computes its state from the constituent stores immediately, with no async coordination. ## Testing stale states @@ -164,7 +164,7 @@ No mock servers. No interceptors. No delay hacks to catch the loading state. Jus The design choice that makes testing easy is the same one that makes the library work: **stores are values, and values are passed as parameters.** -Your component doesn't call `useRemoteData` itself — it receives a `RemoteDataStore` from its parent. +Your component doesn't call `useRemoteData` itself; it receives a `RemoteDataStore` from its parent. This means the component has no idea whether the store came from a hook, from `RemoteDataStore.always()`, from a test, or from server-rendered data. This isn't a testing trick. It's the architecture. The same property that makes SSR simple (pass `initial` data), Storybook simple (pass a static store), and component sharing simple (pass a store as a prop) is what makes testing simple. diff --git a/site/docs/typed-errors.mdx b/site/docs/typed-errors.mdx index 617418d..b6bff98 100644 --- a/site/docs/typed-errors.mdx +++ b/site/docs/typed-errors.mdx @@ -8,18 +8,18 @@ and the error view gets a `retry` button. That's enough for most apps. But sometimes you know _what kind_ of failure happened and want to render it differently. Two common cases: -- **Validation** — you parse the API response with [Zod](https://zod.dev/) and get field-level errors. -- **GraphQL unions** — the API returns `Person | PersonNotFound | PersonDeleted` and each case needs its own UI. +- **Validation:** you parse the API response with [Zod](https://zod.dev/) and get field-level errors. +- **GraphQL unions:** the API returns `Person | PersonNotFound | PersonDeleted` and each case needs its own UI. `useRemoteDataResult` (and `useRemoteDataMapResult` for [dynamic data](dynamic-data)) lets you separate -**expected domain errors** from **unexpected crashes** — and TypeScript knows which is which. +**expected domain errors** from **unexpected crashes**, and TypeScript knows which is which. ## The idea Your fetch function returns a `Result` instead of a plain value: -- `Result.ok(value)` — success -- `Result.err(error)` — a domain error +- `Result.ok(value)`: success +- `Result.err(error)`: a domain error If the promise _rejects_ (network failure, thrown exception), that's an unexpected error handled automatically. If it _resolves_ with `Result.err(...)`, that's a typed domain error you handle explicitly. @@ -69,7 +69,7 @@ function fetchAndValidateUser(): Promise> { ``` If the API returns `{ name: "", email: "not-an-email", age: -5 }`, the store moves to `Failed` -with a `ZodError` containing three issues — one per field. Your error component renders each one: +with a `ZodError` containing three issues, one per field. Your error component renders each one: ```tsx ``` -Try it — the simulated API alternates between valid and invalid responses: +Try it. The simulated API alternates between valid and invalid responses: ## Example: GraphQL union types GraphQL APIs often return unions like `Person | PersonNotFound | PersonDeleted`. -These aren't crashes — they're expected outcomes with structured data. +These aren't crashes; they're expected outcomes with structured data. You decide which variants are successes and which are domain errors: ```tsx @@ -135,4 +135,4 @@ Each element in the `errors` array is a `Failure` with two possible shapes: | `'unexpected'` | `Error \| unknown` | A surprise crash (network failure, thrown exception) | For a single store, `errors` always has one element. -For [combined stores](combining-stores), it can have multiple — one per failed request. +For [combined stores](combining-stores), it can have multiple, one per failed request. diff --git a/site/src/pages/index.js b/site/src/pages/index.js index 6e3fc8f..39ef4fc 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -62,7 +62,7 @@ function UserProfile({ id }) { const codeAwait = ` {(user) => ( - // user is User — never undefined, + // user is User. Never undefined, // never null, never loading.

{user.name}

@@ -129,7 +129,7 @@ const store = useRemoteData( ); // isStale is true while background -// refresh is in progress — old data stays visible +// refresh is in progress, old data stays visible {(prices, isStale) => (
@@ -174,7 +174,7 @@ return ( const codeTesting = ` import { RemoteData, RemoteDataStore, Failure } from "use-remote-data"; -// A store that's already loaded — no fetch, no mock +// A store that's already loaded. No fetch, no mock const store = RemoteDataStore.always( RemoteData.success({ name: "Alice", email: "alice@ex.com" }) ); @@ -200,7 +200,7 @@ function UserPage({ id }) { () => fetchUser(id), { dependencies: [id] } ); - // Pass the store down — child components + // Pass the store down: child components // share the same fetch, same cache. return ( @@ -222,7 +222,7 @@ const quickHits = [ }, { title: 'Lazy by default', - text: 'Stores only fetch when rendered. Define data dependencies upfront — only what mounts hits the network.', + text: 'Stores only fetch when rendered. Define data dependencies upfront; only what mounts hits the network.', }, { title: 'Mutations that refresh', @@ -230,7 +230,7 @@ const quickHits = [ }, { title: 'Typed errors', - text: 'Separate domain errors from crashes. Validate with Zod, handle GraphQL unions — TypeScript knows which error you have.', + text: 'Separate domain errors from crashes. Validate with Zod, handle GraphQL unions; TypeScript knows which error you have.', }, ]; @@ -255,7 +255,7 @@ function Section({ title, text, code, alt, reverse }) { export default function Home() { return ( - + {/* Hero */}
@@ -263,7 +263,7 @@ export default function Home() {

One hook. One component. Your data is T, not T | undefined.
- Loading, error, success — always one state, always type-safe. + Loading, error, success: always one state, always type-safe.

@@ -295,7 +295,7 @@ export default function Home() { {/* Section 2 */}
@@ -311,14 +311,14 @@ export default function Home() { {/* Section 4: Error handling */}
{/* Combine */}
{/* Invalidation */}
Date: Mon, 11 May 2026 01:04:32 +0200 Subject: [PATCH 2/7] ci: add workflow to auto-deploy docs to gh-pages on master Triggers on push to master that touches site/ or the workflow file itself. Uses peaceiris/actions-gh-pages@v4 to build the Docusaurus site and push to the existing gh-pages branch (matches the current manual `docusaurus deploy` flow). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-docs.yml | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/deploy-docs.yml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..ad57c5b --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,42 @@ +name: Deploy docs + +on: + push: + branches: ['master'] + paths: + - 'site/**' + - '.github/workflows/deploy-docs.yml' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: deploy-docs + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: site/package-lock.json + - name: Install dependencies + working-directory: site + run: npm ci + - name: Build site + working-directory: site + run: npm run build + - name: Publish to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' + commit_message: 'Deploy site from ${{ github.sha }}' From 124a8c0e8b75f7eff418882e7d1a746ac7dce174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 11 May 2026 01:06:44 +0200 Subject: [PATCH 3/7] ci: switch docs deploy to actions/deploy-pages Use the modern Pages-from-Actions flow (configure-pages, upload-pages-artifact, deploy-pages) instead of pushing the build output to a gh-pages branch via a third-party action. Pages source already switched to "workflow" via the API. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-docs.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ad57c5b..6739164 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -9,14 +9,16 @@ on: workflow_dispatch: permissions: - contents: write + contents: read + pages: write + id-token: write concurrency: - group: deploy-docs + group: pages cancel-in-progress: false jobs: - deploy: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -25,18 +27,23 @@ jobs: node-version: 22 cache: 'npm' cache-dependency-path: site/package-lock.json + - uses: actions/configure-pages@v5 - name: Install dependencies working-directory: site run: npm ci - name: Build site working-directory: site run: npm run build - - name: Publish to gh-pages - uses: peaceiris/actions-gh-pages@v4 + - uses: actions/upload-pages-artifact@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: site/build - publish_branch: gh-pages - user_name: 'github-actions[bot]' - user_email: 'github-actions[bot]@users.noreply.github.com' - commit_message: 'Deploy site from ${{ github.sha }}' + path: site/build + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 From d94536409c053cd2dd4b5ea51e6344bbf8848eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 11 May 2026 01:08:09 +0200 Subject: [PATCH 4/7] ci: temporarily allow deploy-docs to trigger on this branch Will revert to master-only after verifying the deploy works. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 6739164..27cec14 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -2,7 +2,7 @@ name: Deploy docs on: push: - branches: ['master'] + branches: ['master', 'fix/hooks-rules-compliance'] paths: - 'site/**' - '.github/workflows/deploy-docs.yml' From b77d11dfe93dbc17477355c3e50c1abf5563d6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 11 May 2026 01:34:18 +0200 Subject: [PATCH 5/7] ci: revert deploy-docs trigger to master-only Verification deploy from this branch succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 27cec14..6739164 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -2,7 +2,7 @@ name: Deploy docs on: push: - branches: ['master', 'fix/hooks-rules-compliance'] + branches: ['master'] paths: - 'site/**' - '.github/workflows/deploy-docs.yml' From 37ccb2ed5a06daa50a536593e322ae6ae5c8e446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Wed, 20 May 2026 01:09:55 +0200 Subject: [PATCH 6/7] site: redesign landing page, logos, and tighten readme Monochrome + orange palette with Geist Sans/Mono. Mode-aware Prism themes for code snippets. New {T} brace logo with light/dark variants and self-themed favicon. Readme trimmed to a punchier intro. Co-Authored-By: Claude Opus 4.7 --- readme.md | 19 +- site/docusaurus.config.js | 5 +- site/src/css/custom.css | 251 +++++++++++++-- site/src/pages/index.js | 365 +++++++++++----------- site/src/pages/index.module.css | 519 +++++++++++++++++++++----------- site/static/img/favicon.svg | 15 + site/static/img/logo-dark.svg | 9 + site/static/img/logo.svg | 44 +-- 8 files changed, 801 insertions(+), 426 deletions(-) create mode 100644 site/static/img/favicon.svg create mode 100644 site/static/img/logo-dark.svg diff --git a/readme.md b/readme.md index 55b9e28..bb08a27 100644 --- a/readme.md +++ b/readme.md @@ -1,9 +1,6 @@ # use-remote-data -Async data for React, without the guesswork. - -Your data is always in exactly one state (loading, failed, or succeeded), and you -**cannot access the value without proving it exists**. No `T | undefined`. No boolean flags. No guessing. +A React hook for async data. Loading, error, success: always one state, always type-safe. ```tsx const store = useRemoteData(() => fetchUser(id)); @@ -11,20 +8,14 @@ const store = useRemoteData(() => fetchUser(id)); {(user) => {user.name}}; ``` -Inside the callback, `user` is `User`. Not `User | undefined`. TypeScript enforces it. +Inside the callback, `user` is `User`, not `User | undefined`. You cannot read the value without proving it exists. -On top of this, you get automatic refresh, retry, composing multiple requests, mutations, lazy loading, and typed errors, all with zero dependencies beyond React. +Refresh, retry, mutations, lazy loading, typed errors, composing requests; all in one tiny library with zero dependencies beyond React. **[Read the docs](https://oyvindberg.github.io/use-remote-data/)** ### Prior art -Based on the Remote Data pattern described in: - -- https://medium.com/@gcanti/slaying-a-ui-antipattern-with-flow-5eed0cfb627b - -Related libraries: +Based on the Remote Data pattern: https://medium.com/@gcanti/slaying-a-ui-antipattern-with-flow-5eed0cfb627b -- https://github.com/devexperts/remote-data-ts -- https://github.com/mcollis/remote-data -- https://github.com/skkallayath/react-remote-data-hooks +Related: [remote-data-ts](https://github.com/devexperts/remote-data-ts), [remote-data](https://github.com/mcollis/remote-data), [react-remote-data-hooks](https://github.com/skkallayath/react-remote-data-hooks) diff --git a/site/docusaurus.config.js b/site/docusaurus.config.js index f88b21a..749d957 100644 --- a/site/docusaurus.config.js +++ b/site/docusaurus.config.js @@ -9,15 +9,16 @@ module.exports = { url: 'https://oyvindberg.github.io', baseUrl: '/use-remote-data/', onBrokenLinks: 'throw', - favicon: 'img/favicon.ico', + favicon: 'img/favicon.svg', organizationName: 'oyvindberg', projectName: 'use-remote-data', themeConfig: { navbar: { title: 'use-remote-data', logo: { - alt: 'My Site Logo', + alt: 'use-remote-data', src: 'img/logo.svg', + srcDark: 'img/logo-dark.svg', }, items: [ { diff --git a/site/src/css/custom.css b/site/src/css/custom.css index 28ad1c7..bb07ab6 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -1,42 +1,239 @@ +/* ───────────────────────────────────────────────────────────── + Typography: Geist Sans (display + body), Geist Mono (code). + ───────────────────────────────────────────────────────────── */ + +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800;900&family=Geist+Mono:wght@400;500;600;700&display=swap'); + +/* ───────────────────────────────────────────────────────────── + Palette: Monochrome. Black, white, hairline grey. + Single yellow accent reserved for focus/hover. + ───────────────────────────────────────────────────────────── */ + :root { - --ifm-color-primary: #0d9488; - --ifm-color-primary-dark: #0f766e; - --ifm-color-primary-darker: #115e59; - --ifm-color-primary-darkest: #134e4a; - --ifm-color-primary-light: #14b8a6; - --ifm-color-primary-lighter: #2dd4bf; - --ifm-color-primary-lightest: #5eead4; - --ifm-code-font-size: 95%; - - --landing-text-primary: #111827; - --landing-text-secondary: #4b5563; - --landing-alt-bg: #f0fdfa; - --landing-before-color: #ef4444; - --landing-after-color: #0d9488; + --ifm-color-primary: #000000; + --ifm-color-primary-dark: #000000; + --ifm-color-primary-darker: #000000; + --ifm-color-primary-darkest: #000000; + --ifm-color-primary-light: #1a1a1a; + --ifm-color-primary-lighter: #262626; + --ifm-color-primary-lightest: #404040; + + --ink: #0a0a0a; + --ink-soft: #525252; + --ink-faint: #a3a3a3; + --paper: #ffffff; + --paper-soft: #fafafa; + --paper-cool: #f5f5f5; + --rule: #0a0a0a; + --rule-soft: #e5e5e5; + --rule-mid: #d4d4d4; + --accent: #ea580c; + --accent-soft: #ffedd5; + + --landing-text-primary: var(--ink); + --landing-text-secondary: var(--ink-soft); + --landing-alt-bg: var(--paper-soft); + --landing-after-color: var(--ink); + + --ifm-font-family-base: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --ifm-font-family-monospace: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + --font-display: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + + --ifm-heading-font-family: var(--font-display); + --ifm-heading-font-weight: 700; + --ifm-h1-font-size: 2.5rem; + --ifm-code-font-size: 92%; + --ifm-code-border-radius: 0; + + --ifm-background-color: var(--paper); + --ifm-color-content: var(--ink); + --ifm-color-content-secondary: var(--ink-soft); + + --ifm-link-color: var(--accent); + --ifm-link-hover-color: var(--ink); + --ifm-link-decoration: none; + --ifm-link-hover-decoration: underline; + + --ifm-navbar-background-color: var(--paper); + --ifm-navbar-shadow: none; + --ifm-footer-background-color: var(--ink); + --ifm-footer-color: var(--paper); + --ifm-footer-link-color: var(--paper-cool); + --ifm-footer-link-hover-color: var(--accent); + --ifm-footer-title-color: var(--paper); + --ifm-toc-border-color: var(--rule-soft); } html[data-theme='dark'] { - --ifm-color-primary: #2dd4bf; - --ifm-color-primary-dark: #14b8a6; - --ifm-color-primary-darker: #0d9488; - --ifm-color-primary-darkest: #0f766e; - --ifm-color-primary-light: #5eead4; - --ifm-color-primary-lighter: #99f6e4; - --ifm-color-primary-lightest: #ccfbf1; + --ifm-color-primary: #ffffff; + --ifm-color-primary-dark: #f5f5f5; + --ifm-color-primary-darker: #e5e5e5; + --ifm-color-primary-darkest: #d4d4d4; + --ifm-color-primary-light: #ffffff; + --ifm-color-primary-lighter: #ffffff; + --ifm-color-primary-lightest: #ffffff; + + --ink: #fafafa; + --ink-soft: #a3a3a3; + --ink-faint: #525252; + --paper: #000000; + --paper-soft: #0a0a0a; + --paper-cool: #111111; + --rule: #fafafa; + --rule-soft: #1f1f1f; + --rule-mid: #2a2a2a; + --accent: #fb923c; + --accent-soft: #2a1607; + + --landing-text-primary: var(--ink); + --landing-text-secondary: var(--ink-soft); + --landing-alt-bg: var(--paper-soft); + --landing-after-color: var(--ink); + + --ifm-background-color: var(--paper); + --ifm-color-content: var(--ink); + --ifm-color-content-secondary: var(--ink-soft); + --ifm-navbar-background-color: var(--paper); + --ifm-footer-background-color: #000000; + + --ifm-link-color: var(--accent); + --ifm-link-hover-color: var(--ink); +} + +/* ───────────────────────────────────────────────────────────── + Site-wide + ───────────────────────────────────────────────────────────── */ + +html, +body { + background: var(--paper); + color: var(--ink); + font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-display); + letter-spacing: -0.022em; + color: var(--ink); + font-weight: 700; + font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + text-wrap: balance; +} + +/* Tabular numerals on monospace labels and numerals in code */ +code, +pre, +kbd, +samp { + font-feature-settings: 'liga' on, 'calt' on, 'zero' on, 'ss05' on, 'ss08' on; + font-variant-numeric: tabular-nums; +} + +p, +li { + text-wrap: pretty; +} - --landing-text-primary: #f9fafb; - --landing-text-secondary: #9ca3af; - --landing-alt-bg: #111113; - --landing-after-color: #2dd4bf; +code { + font-family: var(--ifm-font-family-monospace); + background: var(--paper-cool); + color: var(--ink); + border: 1px solid var(--rule-soft); + padding: 0.05em 0.4em; + border-radius: 0; + font-size: 0.88em; + font-weight: 500; +} + +pre code { + background: transparent; + color: inherit; + border: 0; + padding: 0; +} + +pre { + border-radius: 0 !important; +} + +/* Buttons */ +.button { + border-radius: 0; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + border: 1px solid currentColor; +} + +/* Navbar */ +.navbar { + border-bottom: 1px solid var(--rule-soft); + background: var(--paper); + box-shadow: none; +} + +.navbar__title { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--ink); +} + +.navbar__link { + font-weight: 500; + color: var(--ink); +} + +.navbar__link:hover, +.navbar__link--active { + color: var(--ink); + text-decoration: underline; + text-underline-offset: 4px; +} + +/* Selection */ +::selection { + background: var(--accent); + color: var(--ink); +} + +/* Focus rings */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 12px; + height: 12px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--rule-soft); + border: 3px solid var(--paper); +} +::-webkit-scrollbar-thumb:hover { + background: var(--ink-faint); } .docusaurus-highlight-code-line { - background-color: rgba(0, 0, 0, 0.1); + background-color: rgba(0, 0, 0, 0.06); display: block; margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); } html[data-theme='dark'] .docusaurus-highlight-code-line { - background-color: rgba(0, 0, 0, 0.3); + background-color: rgba(255, 255, 255, 0.06); } diff --git a/site/src/pages/index.js b/site/src/pages/index.js index 39ef4fc..3567cf3 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -1,15 +1,62 @@ import styles from './index.module.css'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; -import { Highlight, themes } from 'prism-react-renderer'; +import { useColorMode } from '@docusaurus/theme-common'; +import { Highlight } from 'prism-react-renderer'; import React from 'react'; +// Light Prism theme — dark text on bright background, orange keywords. +const lightTheme = { + plain: { color: '#0a0a0a', backgroundColor: 'transparent' }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#a3a3a3', fontStyle: 'italic' } }, + { types: ['punctuation', 'operator'], style: { color: '#737373' } }, + { types: ['namespace'], style: { opacity: 0.7 } }, + { types: ['string', 'attr-value'], style: { color: '#9a3412' } }, + { types: ['number', 'boolean'], style: { color: '#c2410c' } }, + { types: ['keyword', 'atrule'], style: { color: '#ea580c', fontWeight: '600' } }, + { types: ['function', 'class-name', 'maybe-class-name'], style: { color: '#0a0a0a', fontWeight: '600' } }, + { types: ['tag'], style: { color: '#ea580c' } }, + { types: ['attr-name'], style: { color: '#9a3412' } }, + { types: ['property-access', 'property'], style: { color: '#0a0a0a' } }, + { types: ['variable', 'parameter', 'plain'], style: { color: '#0a0a0a' } }, + { types: ['constant', 'symbol'], style: { color: '#c2410c' } }, + { types: ['regex', 'important'], style: { color: '#9a3412' } }, + { types: ['inserted'], style: { color: '#15803d' } }, + { types: ['deleted'], style: { color: '#b91c1c' } }, + ], +}; + +// Dark Prism theme — light text on near-black background, orange keywords. +const darkTheme = { + plain: { color: '#e5e5e5', backgroundColor: 'transparent' }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#737373', fontStyle: 'italic' } }, + { types: ['punctuation', 'operator'], style: { color: '#a3a3a3' } }, + { types: ['namespace'], style: { opacity: 0.7 } }, + { types: ['string', 'attr-value'], style: { color: '#fcd9b6' } }, + { types: ['number', 'boolean'], style: { color: '#fb923c' } }, + { types: ['keyword', 'atrule'], style: { color: '#fb923c', fontWeight: '600' } }, + { types: ['function', 'class-name', 'maybe-class-name'], style: { color: '#fafafa', fontWeight: '500' } }, + { types: ['tag'], style: { color: '#fb923c' } }, + { types: ['attr-name'], style: { color: '#fcd9b6' } }, + { types: ['property-access', 'property'], style: { color: '#e5e5e5' } }, + { types: ['variable', 'parameter', 'plain'], style: { color: '#e5e5e5' } }, + { types: ['constant', 'symbol'], style: { color: '#fb923c' } }, + { types: ['regex', 'important'], style: { color: '#fcd9b6' } }, + { types: ['inserted'], style: { color: '#86efac' } }, + { types: ['deleted'], style: { color: '#fca5a5' } }, + ], +}; + function Code({ code, language }) { + const { colorMode } = useColorMode(); + const theme = colorMode === 'dark' ? darkTheme : lightTheme; return (
- + {({ style, tokens, getLineProps, getTokenProps }) => ( -
+                    
                         {tokens.map((line, i) => (
                             
{line.map((token, key) => ( @@ -71,24 +118,6 @@ const codeAwait = ` )} `; -const codeInstall = ` -import { useRemoteData, Await } from "use-remote-data"; - -function UserProfile({ id }) { - const store = useRemoteData( - () => fetch(\`/api/users/\${id}\`).then(r => r.json()), - { dependencies: [id] } - ); - - return ( - } - > - {(user) =>

{user.name}

} -
- ); -}`; - const codeError = ` ( @@ -102,16 +131,13 @@ const codeError = ` `; const codeRetry = ` -// When you combine three stores and one fails: +// Combine three stores. One fails. const allStore = RemoteDataStore.all( userStore, postsStore, statsStore ); -// The combined store moves to "failed". -// But retry() only re-fetches the broken one. +// retry() only re-fetches the broken one. // The two successful stores keep their data. -// -// One button. One click. Surgical retry. ( @@ -123,12 +149,11 @@ const allStore = RemoteDataStore.all( const codeRefresh = ` const store = useRemoteData( () => fetchPrices(), { - // Re-fetch every 30 seconds refresh: RefreshStrategy.afterMillis(30_000), } ); -// isStale is true while background +// isStale: true while background // refresh is in progress, old data stays visible {(prices, isStale) => ( @@ -143,19 +168,17 @@ const todosStore = useRemoteData(() => fetchTodos()); const addTodo = useRemoteUpdate( (text) => api.addTodo(text), { - // After a successful mutation, - // automatically re-fetch the todo list refreshes: [todosStore], } ); // addTodo.run("Buy milk") -// → mutation fires -// → on success, todosStore re-fetches -// → Await re-renders with fresh data`; +// → mutation fires +// → on success, todosStore re-fetches +// → Await re-renders with fresh data`; const codeCombine = ` -const userStore = useRemoteData(() => fetchUser(id), { dependencies: [id] }); +const userStore = useRemoteData(() => fetchUser(id), { dependencies: [id] }); const postsStore = useRemoteData(() => fetchPosts(id), { dependencies: [id] }); const statsStore = useRemoteData(() => fetchStats(id), { dependencies: [id] }); @@ -174,7 +197,7 @@ return ( const codeTesting = ` import { RemoteData, RemoteDataStore, Failure } from "use-remote-data"; -// A store that's already loaded. No fetch, no mock +// A store that's already loaded. No fetch, no mock. const store = RemoteDataStore.always( RemoteData.success({ name: "Alice", email: "alice@ex.com" }) ); @@ -193,14 +216,14 @@ const failed = RemoteDataStore.always( const codeLifetime = ` function UserPage({ id }) { - // Store is created when UserPage mounts. + // Store created when UserPage mounts. // Fetches on first render. Caches while mounted. - // Unmount UserPage → store is gone. No stale cache. + // Unmount UserPage: store is gone. No stale cache. const userStore = useRemoteData( () => fetchUser(id), { dependencies: [id] } ); - // Pass the store down: child components + // Pass the store down. Child components // share the same fetch, same cache. return ( @@ -212,10 +235,13 @@ function UserPage({ id }) { const quickHits = [ { - title: 'Zero dependencies, ~3.5kB gzipped', - text: 'Just React. No runtime dependencies, no context providers, no bloat.', + title: 'Zero dependencies', + text: 'Just React. ~3.5kB gzipped, no runtime deps, no context providers, no bloat.', + }, + { + title: 'SSR ready', + text: 'Pass server data as initial. The store starts in Success. No hydration boundaries to wire up.', }, - { title: 'SSR ready', text: 'Pass server data as initial. No hydration boundaries.' }, { title: 'Automatic cancellation', text: 'When deps change or a component unmounts, in-flight requests are aborted. Stale responses are always discarded.', @@ -234,20 +260,16 @@ const quickHits = [ }, ]; -function Section({ title, text, code, alt, reverse }) { - const textBlock = ( -
-

{title}

-

{text}

-
- ); - const codeBlock = ; - +function Section({ label, title, text, code, alt, reverse }) { return (
- {textBlock} - {codeBlock} +
+ {label} +

{title}

+

{text}

+
+
); @@ -256,141 +278,132 @@ function Section({ title, text, code, alt, reverse }) { export default function Home() { return ( - {/* Hero */} -
-
-

Fetch data in React without the boilerplate.

-

- One hook. One component. Your data is T, not T | undefined. -
- Loading, error, success: always one state, always type-safe. -

-
- - Get Started - - - View on GitHub → - +
+ {/* ── Hero ─────────────────────────────────── */} +
+
+

+ Async data. + + Zero guesswork. + +

+ +

+ A React hook with one promise: inside {''} your data is{' '} + T, never T | undefined. Loading, error, success — always one + state, always type-safe. +

+ +
+ + Read the docs → + + npm install use-remote-data +
-
+
+ + {/* ── Before / After ───────────────────────── */} +
+
The old way
-
+
use-remote-data
-
-
- -
- {/* Section 2 */} -
- - {/* Section 3 */} -
- - {/* Section 4: Error handling */} -
- - {/* Combine */} -
- - {/* Section 5: Surgical retry */} -
- - {/* Invalidation */} -
- - {/* Mutation invalidation */} -
- - {/* Testing */} -
- - {/* Lifetime */} -
- - {/* Quick hits */} -
-
-

Everything else you need

-
- {quickHits.map((item) => ( -
-

{item.title}

-

{item.text}

-
- ))} -
-
- {/* Bottom CTA */} -
-

Stop guessing.

-
- - Get Started - - npm install use-remote-data -
-
-
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + {/* ── Everything else ─────────────────── */} +
+
+ 09 / further notes +

Six things you also get.

+ +
+ {quickHits.map((item, i) => ( +
+ + {String(i + 1).padStart(2, '0')} + +

{item.title}

+

{item.text}

+
+ ))} +
+
+
+
+
); } diff --git a/site/src/pages/index.module.css b/site/src/pages/index.module.css index fd101c2..dbf578f 100644 --- a/site/src/pages/index.module.css +++ b/site/src/pages/index.module.css @@ -1,17 +1,19 @@ -/* Hero — light mode: clean white with teal accents */ +/* ───────────────────────────────────────────────────────────── + Landing — sharp monochrome with bold orange + ───────────────────────────────────────────────────────────── */ -.hero { - background: linear-gradient(170deg, #ffffff 0%, #f0fdfa 50%, #ccfbf1 100%); - color: #111827; - padding: 6rem 2rem 5rem; - min-height: 80vh; - display: flex; - align-items: center; +.page { + background: var(--paper); + color: var(--ink); + font-family: var(--font-display); } -:global(html[data-theme='dark']) .hero { - background: #09090b; - color: #f9fafb; +/* ── Hero ────────────────────────────────────────────────── */ + +.hero { + padding: 8rem 2rem 4rem; + border-bottom: 1px solid var(--rule); + background: var(--paper); } .heroInner { @@ -21,86 +23,174 @@ } .heroHeadline { - font-size: 3.2rem; - font-weight: 800; - letter-spacing: -0.02em; - margin: 0 0 1rem; - line-height: 1.1; + font-family: var(--font-display); + font-size: clamp(3rem, 9vw, 7rem); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.052em; + margin: 0 0 2rem; + color: var(--ink); + max-width: 16ch; + text-wrap: balance; + font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; } -.heroHeadline code { - color: #0d9488; - background: none; - font-size: inherit; - padding: 0; +.heroLine { + display: block; } -:global(html[data-theme='dark']) .heroHeadline code { - color: #2dd4bf; +.heroHeadline mark { + background: var(--accent); + color: var(--paper); + padding: 0.02em 0.18em 0.04em; + line-height: 0.95; + display: inline-block; + vertical-align: baseline; + border-radius: 0; + font-weight: 800; + letter-spacing: -0.055em; +} + +html[data-theme='dark'] .heroHeadline mark { + color: var(--ink); + background: var(--accent); } .heroSubtitle { - font-size: 1.25rem; - color: #4b5563; - max-width: 600px; - margin: 0 0 2.5rem; - line-height: 1.6; + font-family: var(--font-display); + font-size: clamp(1.1rem, 1.6vw, 1.3rem); + line-height: 1.42; + color: var(--ink-soft); + max-width: 56ch; + margin: 0 0 3rem; + font-weight: 400; + letter-spacing: -0.005em; + text-wrap: pretty; } -:global(html[data-theme='dark']) .heroSubtitle { - color: #9ca3af; +.heroSubtitle code { + font-family: var(--ifm-font-family-monospace); + background: transparent; + color: var(--ink); + font-weight: 500; + padding: 0 0.05em; + border: 0; + border-radius: 0; + font-size: 0.86em; + letter-spacing: -0.01em; + font-feature-settings: 'liga' on, 'calt' on, 'zero' on; +} + +html[data-theme='dark'] .heroSubtitle code { + color: var(--ink); } .heroCta { display: flex; align-items: center; - gap: 1.5rem; - margin-bottom: 3.5rem; + gap: 1.25rem; + flex-wrap: wrap; } +/* CTAs */ + .ctaButton { - display: inline-block; - background: #0d9488; - color: #fff; - padding: 0.75rem 2rem; - border-radius: 8px; + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--accent); + color: var(--paper); + padding: 0.95rem 1.6rem 1rem; + border: 1px solid var(--accent); + border-radius: 0; + font-family: var(--font-display); font-weight: 600; - font-size: 1rem; + font-size: 0.96rem; + letter-spacing: -0.012em; text-decoration: none; - transition: background 0.15s; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; + font-feature-settings: 'ss01' on, 'cv11' on; +} + +html[data-theme='dark'] .ctaButton { + color: var(--ink); } .ctaButton:hover { - background: #0f766e; - color: #fff; + background: var(--ink); + border-color: var(--ink); + color: var(--paper); text-decoration: none; } -.githubLink { - color: #6b7280; - text-decoration: none; - font-size: 0.95rem; +html[data-theme='dark'] .ctaButton:hover { + background: var(--paper); + color: var(--ink); + border-color: var(--paper); +} + +.ctaButtonGhost { + composes: ctaButton; + background: transparent; + color: var(--ink); + border-color: var(--ink); +} + +.ctaButtonGhost:hover { + background: var(--ink); + color: var(--paper); + border-color: var(--ink); } -.githubLink:hover { - color: #0d9488; - text-decoration: underline; +html[data-theme='dark'] .ctaButtonGhost { + color: var(--ink); + border-color: var(--ink); } -:global(html[data-theme='dark']) .githubLink { - color: #9ca3af; +html[data-theme='dark'] .ctaButtonGhost:hover { + background: var(--ink); + color: var(--paper); + border-color: var(--ink); } -:global(html[data-theme='dark']) .githubLink:hover { - color: #2dd4bf; +.installCmd { + font-family: var(--ifm-font-family-monospace); + font-size: 0.92rem; + color: var(--ink); + background: var(--paper-cool); + border: 1px solid var(--rule-soft); + padding: 0.85rem 1.25rem; + user-select: all; + cursor: copy; + display: inline-flex; + align-items: center; + gap: 0.6rem; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; + font-feature-settings: 'liga' on, 'calt' on, 'zero' on; } -/* Hero comparison */ +.installCmd::before { + content: '$'; + color: var(--accent); + font-weight: 600; + user-select: none; +} + +/* ── Comparison ──────────────────────────────────────────── */ .comparison { + padding: 4rem 2rem; + background: var(--paper-soft); + border-bottom: 1px solid var(--rule); +} + +.comparisonInner { + max-width: 1200px; + margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; - gap: 1.5rem; + gap: 1.25rem; } .comparisonPane { @@ -108,46 +198,57 @@ flex-direction: column; } +.comparisonPane.after { + /* nothing extra; the dark code block is the frame */ +} + .comparisonLabel { - font-size: 0.8rem; + display: flex; + align-items: center; + gap: 0.6rem; + font-family: var(--ifm-font-family-monospace); + font-size: 0.72rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 0.5rem; + letter-spacing: 0.16em; + margin-bottom: 1.25rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + font-feature-settings: 'zero' on, 'ss05' on; } -.labelBefore { - color: #ef4444; +.labelBefore::before { + content: '✕'; + color: var(--ink-soft); } .labelAfter { - color: var(--landing-after-color); + color: var(--accent); +} + +.labelAfter::before { + content: '✓'; + color: var(--accent); } -/* Sections */ +/* ── Sections ────────────────────────────────────────────── */ .section { - padding: 5rem 2rem; + padding: 6rem 2rem; + border-bottom: 1px solid var(--rule-soft); + background: var(--paper); } .sectionAlt { - background: var(--landing-alt-bg); -} - -.sectionInner { - max-width: 1100px; - margin: 0 auto; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4rem; - align-items: center; + background: var(--paper-soft); } +.sectionInner, .sectionInnerReverse { - max-width: 1100px; + max-width: 1200px; margin: 0 auto; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr 1.4fr; gap: 4rem; align-items: center; } @@ -160,159 +261,242 @@ order: 1; } +.sectionLabel { + font-family: var(--ifm-font-family-monospace); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent); + margin: 0 0 1.1rem; + display: block; + font-variant-numeric: tabular-nums; + font-feature-settings: 'zero' on, 'ss05' on; +} + .sectionTitle { - font-size: 2rem; + font-family: var(--font-display); + font-size: clamp(1.8rem, 3.4vw, 2.6rem); font-weight: 700; - letter-spacing: -0.01em; - margin: 0 0 1rem; - color: var(--landing-text-primary); + line-height: 1.05; + letter-spacing: -0.032em; + margin: 0 0 1.25rem; + color: var(--ink); + text-wrap: balance; + font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; } .sectionText { - font-size: 1.1rem; - color: var(--landing-text-secondary); - line-height: 1.7; + font-family: var(--font-display); + font-size: 1.06rem; + color: var(--ink-soft); + line-height: 1.55; margin: 0; + max-width: 44ch; + letter-spacing: -0.005em; + text-wrap: pretty; } -/* Code blocks */ +.sectionText code { + background: var(--paper-cool); + color: var(--ink); + border: 1px solid var(--rule-soft); + padding: 0.05em 0.35em; + font-size: 0.86em; + letter-spacing: -0.01em; +} +/* Code blocks — bright in light mode, dark in dark mode */ .codeBlock { - border-radius: 10px; - overflow: hidden; + background: #fafafa; + border: 1px solid var(--rule-soft); + color: var(--ink); +} + +html[data-theme='dark'] .codeBlock { + background: #0a0a0a; + border: 1px solid var(--rule-mid); + color: #e5e5e5; } .codeBlock pre { margin: 0; - padding: 1.25rem 1.5rem; - font-size: 0.875rem; - line-height: 1.6; + padding: 1.5rem 1.6rem 1.6rem; + font-size: 0.84rem; + line-height: 1.65; overflow-x: auto; - border-radius: 10px; + background: transparent !important; + border-radius: 0 !important; + font-family: var(--ifm-font-family-monospace); + font-feature-settings: 'liga' on, 'calt' on; + color: inherit; } -/* Quick hits grid */ +/* ── Quick hits — clean grid ─────────────────────────────── */ .gridSection { - padding: 5rem 2rem; + padding: 6rem 2rem; + background: var(--paper); + border-bottom: 1px solid var(--rule); } .gridInner { - max-width: 1100px; + max-width: 1200px; margin: 0 auto; } +.gridLabel { + font-family: var(--ifm-font-family-monospace); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent); + margin: 0 0 1.1rem; + display: block; + font-variant-numeric: tabular-nums; + font-feature-settings: 'zero' on, 'ss05' on; +} + .gridTitle { - font-size: 2rem; + font-family: var(--font-display); + font-size: clamp(1.8rem, 3.4vw, 2.6rem); font-weight: 700; - text-align: center; + letter-spacing: -0.032em; margin: 0 0 3rem; - color: var(--landing-text-primary); + color: var(--ink); + max-width: 22ch; + line-height: 1.05; + text-wrap: balance; + font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 2rem; + border: 1px solid var(--rule); } -.gridItem h3 { - font-size: 1.1rem; - font-weight: 700; - margin: 0 0 0.4rem; - color: var(--landing-text-primary); +.gridItem { + padding: 2rem 1.75rem; + border-right: 1px solid var(--rule-soft); + border-bottom: 1px solid var(--rule-soft); + display: flex; + flex-direction: column; + gap: 0.6rem; } -.gridItem p { - font-size: 0.95rem; - color: var(--landing-text-secondary); - line-height: 1.6; - margin: 0; +.gridItem:nth-child(3n) { + border-right: 0; } -/* Bottom CTA */ - -.bottomCta { - background: linear-gradient(170deg, #0f766e 0%, #134e4a 100%); - color: #f9fafb; - padding: 5rem 2rem; - text-align: center; +.gridItem:nth-last-child(-n + 3) { + border-bottom: 0; } -:global(html[data-theme='dark']) .bottomCta { - background: #09090b; +.gridItemNumber { + font-family: var(--ifm-font-family-monospace); + font-size: 0.7rem; + color: var(--accent); + letter-spacing: 0.12em; + margin-bottom: 0.2rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + font-feature-settings: 'zero' on, 'ss05' on; } -.bottomCtaHeadline { - font-size: 2.5rem; - font-weight: 800; - margin: 0 0 2rem; +.gridItem h3 { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.022em; + margin: 0; + color: var(--ink); + line-height: 1.2; + text-wrap: balance; } -.bottomCtaActions { - display: flex; - align-items: center; - justify-content: center; - gap: 2rem; - flex-wrap: wrap; +.gridItem p { + font-size: 0.95rem; + color: var(--ink-soft); + line-height: 1.5; + margin: 0; + letter-spacing: -0.005em; + text-wrap: pretty; } -.bottomCta .ctaButton { - background: #fff; - color: #134e4a; -} +/* ── Reveal ──────────────────────────────────────────────── */ -.bottomCta .ctaButton:hover { - background: #f0fdfa; - color: #134e4a; +.reveal { + opacity: 0; + transform: translateY(8px); + animation: rev 600ms cubic-bezier(0.2, 0.65, 0.2, 1) forwards; } -:global(html[data-theme='dark']) .bottomCta .ctaButton { - background: #2dd4bf; - color: #09090b; +.r1 { + animation-delay: 0ms; } - -:global(html[data-theme='dark']) .bottomCta .ctaButton:hover { - background: #14b8a6; - color: #09090b; +.r2 { + animation-delay: 100ms; +} +.r3 { + animation-delay: 220ms; +} +.r4 { + animation-delay: 360ms; } -.installCmd { - font-family: var(--ifm-font-family-monospace); - font-size: 0.95rem; - color: #ccfbf1; - background: rgba(255, 255, 255, 0.15); - padding: 0.6rem 1.25rem; - border-radius: 8px; +@keyframes rev { + to { + opacity: 1; + transform: translateY(0); + } } -:global(html[data-theme='dark']) .installCmd { - color: #9ca3af; - background: #1a1a1f; +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + animation: none; + } } -/* Responsive */ +/* ── Responsive ──────────────────────────────────────────── */ -@media screen and (max-width: 768px) { +@media screen and (max-width: 968px) { .hero { - padding: 4rem 1.5rem 3rem; - min-height: auto; + padding: 5rem 1.5rem 3rem; } .heroHeadline { - font-size: 2rem; + font-size: clamp(2.5rem, 11vw, 4rem); } .heroSubtitle { font-size: 1.05rem; + max-width: none; + } + + .heroCta { + flex-direction: column; + align-items: stretch; + } + + .heroCta > * { + width: 100%; + justify-content: center; } .comparison { + padding: 2.5rem 1rem; + } + + .comparisonInner { grid-template-columns: 1fr; } .section { - padding: 3rem 1.5rem; + padding: 4rem 1.5rem; } .sectionInner, @@ -329,33 +513,26 @@ order: 2; } - .sectionTitle { - font-size: 1.5rem; + .gridSection { + padding: 4rem 1.5rem; } .grid { grid-template-columns: 1fr; } - .gridTitle { - font-size: 1.5rem; + .gridItem, + .gridItem:nth-child(3n) { + border-right: 0; + border-bottom: 1px solid var(--rule-soft); } - .bottomCtaHeadline { - font-size: 1.75rem; + .gridItem:last-child { + border-bottom: 0; } .codeBlock pre { - font-size: 0.8rem; - } -} - -@media screen and (min-width: 769px) and (max-width: 968px) { - .heroHeadline { - font-size: 2.5rem; - } - - .grid { - grid-template-columns: repeat(2, 1fr); + font-size: 0.78rem; + padding: 1.1rem 1.1rem; } } diff --git a/site/static/img/favicon.svg b/site/static/img/favicon.svg new file mode 100644 index 0000000..e3de6ca --- /dev/null +++ b/site/static/img/favicon.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/site/static/img/logo-dark.svg b/site/static/img/logo-dark.svg new file mode 100644 index 0000000..4b52ebf --- /dev/null +++ b/site/static/img/logo-dark.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/site/static/img/logo.svg b/site/static/img/logo.svg index c809830..69a4f0e 100644 --- a/site/static/img/logo.svg +++ b/site/static/img/logo.svg @@ -1,37 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + From 375dcc380084d626664b80243f145e2ee4aa6d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Wed, 20 May 2026 01:13:31 +0200 Subject: [PATCH 7/7] style: run prettier Co-Authored-By: Claude Opus 4.7 --- site/docs/getting-started.mdx | 24 +++++++------- site/docs/shared-stores.mdx | 14 ++++---- site/src/css/custom.css | 19 +++++++++-- site/src/pages/index.js | 12 +++---- site/src/pages/index.module.css | 57 ++++++++++++++++++++++++++------- 5 files changed, 85 insertions(+), 41 deletions(-) diff --git a/site/docs/getting-started.mdx b/site/docs/getting-started.mdx index ed34fb6..6119e82 100644 --- a/site/docs/getting-started.mdx +++ b/site/docs/getting-started.mdx @@ -110,18 +110,18 @@ You'll want to provide your own rendering. There are two ways: Both react-query and SWR fetch data well. The difference is what happens after. -| | react-query / SWR | use-remote-data | -| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | -| Data type | `T \| undefined` | `T` (inside ``) | -| "Did I forget to check loading?" | Possible | Structurally impossible | -| Stale data during refetch | Yes (`isFetching` / `isValidating`) | Yes (`isStale` flag) | -| Retry on error | Automatic + manual `refetch()` | `retry()` callback in Failed state | -| Combining requests | `useQueries` (typed arrays) | `RemoteDataStore.all()` (typed tuples) | -| Global cache | Yes, deduplication across components | Opt-in via [`SharedStoreProvider`](shared-stores); local by default | -| Runtime dependencies | One (`@tanstack/query-core` / `swr`) | Zero | -| Providers / context | `QueryClientProvider` / `SWRConfig` | None | -| Performance | Per-query cache + observer overhead | [Faster at every sharing level](performance) | -| React Compiler | Supported | Supported, with zero per-render allocations | +| | react-query / SWR | use-remote-data | +| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------- | +| Data type | `T \| undefined` | `T` (inside ``) | +| "Did I forget to check loading?" | Possible | Structurally impossible | +| Stale data during refetch | Yes (`isFetching` / `isValidating`) | Yes (`isStale` flag) | +| Retry on error | Automatic + manual `refetch()` | `retry()` callback in Failed state | +| Combining requests | `useQueries` (typed arrays) | `RemoteDataStore.all()` (typed tuples) | +| Global cache | Yes, deduplication across components | Opt-in via [`SharedStoreProvider`](shared-stores); local by default | +| Runtime dependencies | One (`@tanstack/query-core` / `swr`) | Zero | +| Providers / context | `QueryClientProvider` / `SWRConfig` | None | +| Performance | Per-query cache + observer overhead | [Faster at every sharing level](performance) | +| React Compiler | Supported | Supported, with zero per-render allocations | react-query and SWR are great libraries with large ecosystems. If you need a browser-extension devtools panel or automatic cross-component deduplication by default, they're solid choices. diff --git a/site/docs/shared-stores.mdx b/site/docs/shared-stores.mdx index 9b7e9b1..36f4d27 100644 --- a/site/docs/shared-stores.mdx +++ b/site/docs/shared-stores.mdx @@ -143,13 +143,13 @@ const userOrNull = sharedUser.orNull; ## When to use `useRemoteData` vs `useSharedRemoteData` -| | `useRemoteData` | `useSharedRemoteData` | -| -------------------- | -------------------------------------- | -------------------------------------------- | -| Store lifetime | Component that called the hook | While any subscriber is mounted (+ `gcTime`) | -| Deduplication | None; each call creates its own store | By name; same name = same store | -| Requires provider | No | Yes (``) | -| Prop-drilling needed | Yes, for sharing | No; same name = same store | -| Best for | Data owned by one component tree | Data shared across unrelated components | +| | `useRemoteData` | `useSharedRemoteData` | +| -------------------- | ------------------------------------- | -------------------------------------------- | +| Store lifetime | Component that called the hook | While any subscriber is mounted (+ `gcTime`) | +| Deduplication | None; each call creates its own store | By name; same name = same store | +| Requires provider | No | Yes (``) | +| Prop-drilling needed | Yes, for sharing | No; same name = same store | +| Best for | Data owned by one component tree | Data shared across unrelated components | **Start with `useRemoteData`** and pass stores as props. It's simpler, has no provider, no cache to configure, and is [faster at every sharing level](performance). Reach for `useSharedRemoteData` when prop-passing truly isn't practical (deeply nested trees, unrelated component subtrees), or as a migration bridge from react-query. diff --git a/site/src/css/custom.css b/site/src/css/custom.css index bb07ab6..1c26007 100644 --- a/site/src/css/custom.css +++ b/site/src/css/custom.css @@ -108,7 +108,11 @@ html, body { background: var(--paper); color: var(--ink); - font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + font-feature-settings: + 'ss01' on, + 'ss03' on, + 'cv01' on, + 'cv11' on; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -124,7 +128,11 @@ h6 { letter-spacing: -0.022em; color: var(--ink); font-weight: 700; - font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + font-feature-settings: + 'ss01' on, + 'ss03' on, + 'cv01' on, + 'cv11' on; text-wrap: balance; } @@ -133,7 +141,12 @@ code, pre, kbd, samp { - font-feature-settings: 'liga' on, 'calt' on, 'zero' on, 'ss05' on, 'ss08' on; + font-feature-settings: + 'liga' on, + 'calt' on, + 'zero' on, + 'ss05' on, + 'ss08' on; font-variant-numeric: tabular-nums; } diff --git a/site/src/pages/index.js b/site/src/pages/index.js index 3567cf3..ce5e527 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -1,7 +1,7 @@ import styles from './index.module.css'; import Link from '@docusaurus/Link'; -import Layout from '@theme/Layout'; import { useColorMode } from '@docusaurus/theme-common'; +import Layout from '@theme/Layout'; import { Highlight } from 'prism-react-renderer'; import React from 'react'; @@ -290,9 +290,9 @@ export default function Home() {

- A React hook with one promise: inside {''} your data is{' '} - T, never T | undefined. Loading, error, success — always one - state, always type-safe. + A React hook with one promise: inside {''} your data is T, + never T | undefined. Loading, error, success — always one state, always + type-safe.

@@ -392,9 +392,7 @@ export default function Home() {
{quickHits.map((item, i) => (
- - {String(i + 1).padStart(2, '0')} - + {String(i + 1).padStart(2, '0')}

{item.title}

{item.text}

diff --git a/site/src/pages/index.module.css b/site/src/pages/index.module.css index dbf578f..d90b63e 100644 --- a/site/src/pages/index.module.css +++ b/site/src/pages/index.module.css @@ -32,7 +32,11 @@ color: var(--ink); max-width: 16ch; text-wrap: balance; - font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + font-feature-settings: + 'ss01' on, + 'ss03' on, + 'cv01' on, + 'cv11' on; } .heroLine { @@ -78,7 +82,10 @@ html[data-theme='dark'] .heroHeadline mark { border-radius: 0; font-size: 0.86em; letter-spacing: -0.01em; - font-feature-settings: 'liga' on, 'calt' on, 'zero' on; + font-feature-settings: + 'liga' on, + 'calt' on, + 'zero' on; } html[data-theme='dark'] .heroSubtitle code { @@ -108,8 +115,13 @@ html[data-theme='dark'] .heroSubtitle code { font-size: 0.96rem; letter-spacing: -0.012em; text-decoration: none; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; - font-feature-settings: 'ss01' on, 'cv11' on; + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; + font-feature-settings: + 'ss01' on, + 'cv11' on; } html[data-theme='dark'] .ctaButton { @@ -167,7 +179,10 @@ html[data-theme='dark'] .ctaButtonGhost:hover { gap: 0.6rem; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; - font-feature-settings: 'liga' on, 'calt' on, 'zero' on; + font-feature-settings: + 'liga' on, + 'calt' on, + 'zero' on; } .installCmd::before { @@ -214,7 +229,9 @@ html[data-theme='dark'] .ctaButtonGhost:hover { margin-bottom: 1.25rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; - font-feature-settings: 'zero' on, 'ss05' on; + font-feature-settings: + 'zero' on, + 'ss05' on; } .labelBefore::before { @@ -271,7 +288,9 @@ html[data-theme='dark'] .ctaButtonGhost:hover { margin: 0 0 1.1rem; display: block; font-variant-numeric: tabular-nums; - font-feature-settings: 'zero' on, 'ss05' on; + font-feature-settings: + 'zero' on, + 'ss05' on; } .sectionTitle { @@ -283,7 +302,11 @@ html[data-theme='dark'] .ctaButtonGhost:hover { margin: 0 0 1.25rem; color: var(--ink); text-wrap: balance; - font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + font-feature-settings: + 'ss01' on, + 'ss03' on, + 'cv01' on, + 'cv11' on; } .sectionText { @@ -328,7 +351,9 @@ html[data-theme='dark'] .codeBlock { background: transparent !important; border-radius: 0 !important; font-family: var(--ifm-font-family-monospace); - font-feature-settings: 'liga' on, 'calt' on; + font-feature-settings: + 'liga' on, + 'calt' on; color: inherit; } @@ -355,7 +380,9 @@ html[data-theme='dark'] .codeBlock { margin: 0 0 1.1rem; display: block; font-variant-numeric: tabular-nums; - font-feature-settings: 'zero' on, 'ss05' on; + font-feature-settings: + 'zero' on, + 'ss05' on; } .gridTitle { @@ -368,7 +395,11 @@ html[data-theme='dark'] .codeBlock { max-width: 22ch; line-height: 1.05; text-wrap: balance; - font-feature-settings: 'ss01' on, 'ss03' on, 'cv01' on, 'cv11' on; + font-feature-settings: + 'ss01' on, + 'ss03' on, + 'cv01' on, + 'cv11' on; } .grid { @@ -402,7 +433,9 @@ html[data-theme='dark'] .codeBlock { margin-bottom: 0.2rem; font-weight: 600; font-variant-numeric: tabular-nums; - font-feature-settings: 'zero' on, 'ss05' on; + font-feature-settings: + 'zero' on, + 'ss05' on; } .gridItem h3 {