diff --git a/site/blog/2026-05-20-values-all-the-way-down.mdx b/site/blog/2026-05-20-values-all-the-way-down.mdx new file mode 100644 index 0000000..5a81b65 --- /dev/null +++ b/site/blog/2026-05-20-values-all-the-way-down.mdx @@ -0,0 +1,248 @@ +--- +title: The missing prop +authors: [oyvind] +tags: [react, typescript, architecture] +--- + +# The missing prop + +Here's a claim that sounds too simple to be interesting: **if your component receives data as a prop, testing, SSR, and Storybook all become the same trivially easy operation.** + +{/* truncate */} + +Not easier. Not "easier with the right setup." Trivially easy. Three lines. No mocks, no providers, no async coordination, no timing tricks: + +```tsx +// Test +render(); +expect(screen.getByText('Alice')).toBeInTheDocument(); + +// Storybook +export const Loaded = () => ; +export const Loading = () => ; + +// SSR +const store = useRemoteData(() => fetchUser(id), { initial: RemoteData.success(serverData) }); +// Renders on the server with real data. No hydration boundary. No dehydration. +``` + +These aren't three separate features. They're the same thing: **a component that receives a value doesn't care where the value came from.** A test manufactures it. Storybook manufactures it. A server manufactures it. The component renders identically in all three cases because its only dependency is its props. + +## What we're actually talking about + +When I say "value," I don't mean something pure or immutable in the strict functional programming sense. A `RemoteDataStore` from `useRemoteData` is, honestly, a mutable stateful object internally. It has abort controllers, request versioning, refresh timers. It changes over time. + +But from the _consumer's_ perspective, it has a value-like interface. You receive it as a prop. You pass it to ``. You can combine it with other stores via `RemoteDataStore.all()`. You can transform it with `.map()`. And critically, you can manufacture a fake one with `RemoteDataStore.fromValue(data)` that the consuming component _cannot distinguish_ from a real one. + +That's what matters. Not purity in the academic sense, but _substitutability_ in the practical sense. Can I give my component a store without setting up the universe? If yes, the component is independent. If no, it's a fragment of a larger system pretending to be independent. + +## The infrastructure tax + +Most data-fetching libraries create an invisible dependency between your component and a global system. Your component calls `useQuery`, which talks to a `QueryClient`, which lives in a React context provider that must wrap your component tree. + +This works fine in production. The provider is there, the cache is configured, everything connects. The cost shows up everywhere _else_: + +**Testing:** You need a `QueryClientProvider`, a `QueryClient` instance, probably `msw` or `nock` to intercept HTTP requests, `waitFor` to handle the async state transition, and `act()` to flush updates. A 3-line test becomes a 30-line test. Not because the component is complex, but because the _infrastructure_ is complex. + +**Storybook:** You need the same provider setup, plus either a mock server running or interceptors configured, plus timing hacks to capture the loading state before data arrives. Many teams give up and only write stories for the "loaded" state. + +**SSR:** You need to serialize the query client's cache on the server, ship it in the HTML, and restore it on the client inside a ``. This is conceptual overhead that exists because the cache is global state that must survive the server/client transition. + +None of this infrastructure exists because _your component_ is complex. It exists because your component has a hidden dependency on a global system, and recreating that global system in each context (test, story, server) takes work. + +Remove the hidden dependency (make the store a prop) and the infrastructure vanishes. Not because you've done something clever, but because there was never an inherent reason for it to exist. + +## The prop drilling question + +The objection: "If stores are props, doesn't that mean prop drilling? Doesn't that mean a `` needs a `userStore` prop just because some deeply nested `` uses it?" + +It doesn't have to. React already has the tools to solve this without hiding dependencies. You just have to use them. + +### Pass elements, not data + +The first tool is the one people forget about: `ReactNode` as a prop. Instead of drilling data through intermediate layers, build the component higher up and pass the _element_ through: + +```tsx +// Instead of this (drilling userStore through Layout) +function App() { + const userStore = useRemoteData(() => fetchUser()); + return ; // Layout doesn't use it, just passes it down +} + +// Do this: build the element at the top, pass it as a slot +function App() { + const userStore = useRemoteData(() => fetchUser()); + return ( + + {(user) => } + + } + /> + ); +} +``` + +`Layout` doesn't know about users. It doesn't have a `userStore` prop. It just has a `sidebar: ReactNode` slot and renders it wherever it belongs. The user data stays at the level that owns it, and the rendered element flows down. + +This is inversion of control, and React supports it natively. A component that accepts `ReactNode` props is saying "you decide what goes here, I decide where it goes." No data needs to pass through. No interface pollution. + +### Bundle props into objects + +The second tool is even simpler: data modeling. If you're passing five related props through multiple layers, that's not a drilling problem; it's a missing abstraction: + +```tsx +// Instead of drilling five props +function Page({ user, posts, stats, onSave, onDelete }: ...) { ... } + +// Model the dependency +interface DashboardData { + user: User; + posts: Post[]; + stats: Stats; +} + +interface DashboardActions { + onSave: (data: FormData) => void; + onDelete: (id: string) => void; +} + +function Page({ data, actions }: { data: DashboardData; actions: DashboardActions }) { ... } +``` + +Five props become two. Give them a name that describes what they are. This is data modeling: grouping related things and naming the group. The same principle that makes a `User` type better than three loose strings applies to component interfaces. + +### Use callbacks for data the parent doesn't have + +Sometimes a child needs to combine parent data with data only it has, like a selected row ID, a form value, or a scroll position. That's a callback: + +```tsx +function Parent() { + const userStore = useRemoteData(() => fetchUser()); + return ( + + {(user) => navigate(`/users/${user.id}/posts/${postId}`)} />} + + ); +} +``` + +The parent provides the user context via a callback. `PostList` doesn't need to know about users; it just calls `onSelectPost` with the data it has. The wiring happens at the boundary. + +Between element slots, named prop objects, and callbacks, most "prop drilling" problems dissolve. The data stays at the level that owns it. The components below stay independent. + +## One interface, every context + +Here's the `UserCard` component: + +```tsx +function UserCard({ store }: { store: RemoteDataStore }) { + return ( + + {(user) => ( +
+

{user.name}

+

{user.email}

+
+ )} +
+ ); +} +``` + +This component cannot tell the difference between: + +- A store created by `useRemoteData` that fetched from an API +- A store created by `RemoteDataStore.fromValue(testUser)` in a test +- A store pre-filled with server data via the `initial` option +- A store shared across components via `SharedStoreProvider` +- A store combined from three other stores with `RemoteDataStore.all()` + +In every case, the component receives a `RemoteDataStore` and renders it. That's the contract. Everything outside that contract (how the data was fetched, whether it's cached, whether it's shared, whether it's server-rendered) is someone else's decision. + +This is what makes the testing/Storybook/SSR story work. It's not a feature of the library. It's a consequence of the interface. When a component's only dependency is a value in its props, you can satisfy that dependency from anywhere. + +### Testing + +```tsx +test('renders the user', () => { + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); +}); + +test('renders loading', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); +}); + +test('renders error with retry', () => { + const retry = vi.fn().mockResolvedValue(undefined); + const store = RemoteDataStore.of(RemoteData.Failed([Failure.unexpected(new Error('timeout'))], retry)); + render(); + expect(screen.getByText(/timeout/)).toBeInTheDocument(); +}); +``` + +No `waitFor`. No `act()`. No mock server. The store is already in the state you want, so the component renders synchronously. Three states, three tests, nine lines of assertions. + +To be fair: you could achieve similar test simplicity with react-query by splitting your component into a "container" that calls `useQuery` and a "presentational" component that receives `data: T` as a prop. That's a valid pattern. The difference is that `RemoteDataStore` carries _all_ the states (loading, error, success, stale) as a single prop. You don't need to split your component to test the loading state. You don't need a separate wrapper to test the error state. The store is the complete async lifecycle in one value. + +### Storybook + +```tsx +export const Loaded = () => ( + +); + +export const Loading = () => ; + +export const Error = () => ( + {}) + )} + /> +); + +export const Refreshing = () => ( + +); +``` + +Four stories. Four states. No mock server. No interceptors. No delay hacks to catch the loading state before data arrives. The stale-data-during-refresh state, which is almost impossible to capture in a Storybook story with most libraries, is just another constructor call. + +### SSR + +```tsx +const store = useRemoteData(() => fetchUser(id), { + initial: RemoteData.success(serverData), + refresh: RefreshStrategy.afterMillis(30_000), +}); +``` + +`` sees `Success` on the very first render. The HTML includes the real data. After hydration, the refresh strategy kicks in. No ``. No dehydration step. No serialization config. SSR with other libraries is complex because they serialize a global cache across the server/client boundary. When stores are props initialized with data, there's nothing to serialize. + +## The complexity budget argument + +Global caches work. react-query is a good library. Teams ship production apps with it every day without cache key collisions or stale data bugs. + +But a global cache is still one more thing in your complexity budget. It's a `QueryClient` to configure. A provider to wrap your tree in. Cache keys to name consistently. Stale times to tune. Invalidation logic to get right. Devtools to learn. Hydration boundaries for SSR. + +Each of these is individually manageable. Together, they're a system, and systems have a cognitive cost even when they work correctly. Every new team member needs to understand the cache semantics. Every debugging session needs to account for what the cache might be doing. + +The question isn't "does a global cache work?" It's "do you need one?" For most components, the answer is no. Data is fetched by a parent, consumed by children, and garbage-collected on unmount. Component-scoped stores handle this with zero configuration. For the cases where you genuinely need cross-component deduplication, opt-in shared state handles it without imposing cache semantics on the 80% of components that don't need them. + +## Where this leaves us + +The principle is simple: **make data a prop, and every context becomes easy.** Testing is easy because you can manufacture the prop. Storybook is easy because you can manufacture the prop. SSR is easy because you can manufacture the prop. The component doesn't know and doesn't care. + +This isn't a new idea. It's React's original idea (components are functions from data to UI) applied to async state. The store-as-prop interface makes the components independent. The rest follows. + +I won't pretend this is free. You give up automatic deduplication by default (though it's available opt-in). You give up devtools (though `debug: console.warn` traces every state transition). You accept that data flows through the tree as props, which sometimes means passing a store through a layer or two that doesn't use it directly. + +In exchange, you get components that are genuinely independent. That work in tests without infrastructure. That render in Storybook without timing hacks. That support SSR without hydration boundaries. That compose into typed tuples with surgical retry. + +Values compose. That's the whole post. diff --git a/site/blog/authors.yml b/site/blog/authors.yml new file mode 100644 index 0000000..8d49da9 --- /dev/null +++ b/site/blog/authors.yml @@ -0,0 +1,3 @@ +oyvind: + name: Øyvind Raddum Berg + url: https://github.com/oyvindberg diff --git a/site/docs/migrating-from-react-query.mdx b/site/docs/migrating-from-react-query.mdx index 96d8c74..1c16f9c 100644 --- a/site/docs/migrating-from-react-query.mdx +++ b/site/docs/migrating-from-react-query.mdx @@ -143,7 +143,7 @@ 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.of(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 diff --git a/site/docs/testing.mdx b/site/docs/testing.mdx index a24c618..bedc2f1 100644 --- a/site/docs/testing.mdx +++ b/site/docs/testing.mdx @@ -8,21 +8,21 @@ Most data-fetching libraries require mocking to test. You mock the HTTP layer, o No mocking. No providers. No test utilities. Just data. -## `RemoteDataStore.always()`: a store in any state +## `RemoteDataStore.of()`: 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: +The key function for testing is `RemoteDataStore.of()`. It creates a store that's permanently in whatever state you give it: ```tsx import { RemoteData, RemoteDataStore } from 'use-remote-data'; // A store that has data -const loaded = RemoteDataStore.always(RemoteData.success({ name: 'Alice', email: 'alice@example.com' })); +const loaded = RemoteDataStore.of(RemoteData.success({ name: 'Alice', email: 'alice@example.com' })); // A store that's loading -const loading = RemoteDataStore.always(RemoteData.Pending); +const loading = RemoteDataStore.of(RemoteData.Pending); // A store that hasn't started -const idle = RemoteDataStore.always(RemoteData.Initial); +const idle = RemoteDataStore.of(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. @@ -53,13 +53,13 @@ import { render, screen } from '@testing-library/react'; import { Failure, RemoteData, RemoteDataStore } from 'use-remote-data'; test('renders the user when data is available', () => { - const store = RemoteDataStore.always(RemoteData.success({ name: 'Alice', email: 'alice@example.com' })); + const store = RemoteDataStore.of(RemoteData.success({ name: 'Alice', email: 'alice@example.com' })); render(); expect(screen.getByText('Alice')).toBeInTheDocument(); }); test('renders a spinner while loading', () => { - const store = RemoteDataStore.always(RemoteData.Pending); + const store = RemoteDataStore.of(RemoteData.Pending); render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); @@ -68,7 +68,7 @@ test('renders an error with retry', () => { const retry = vi.fn().mockResolvedValue(undefined); // Failure.unexpected() wraps a crash (like a network failure). // The errors array is one element for a single store. - const store = RemoteDataStore.always(RemoteData.Failed([Failure.unexpected(new Error('Network error'))], retry)); + const store = RemoteDataStore.of(RemoteData.Failed([Failure.unexpected(new Error('Network error'))], retry)); render(); expect(screen.getByText(/Network error/)).toBeInTheDocument(); }); @@ -78,12 +78,12 @@ No `act()` wrappers. No `waitFor()`. No timers. The store is already in the stat ## Testing combined stores -`RemoteDataStore.all()` works with `always()` stores. You can test how your component behaves when one request succeeds and another fails: +`RemoteDataStore.all()` works with `of()` stores. You can test how your component behaves when one request succeeds and another fails: ```tsx test('dashboard renders when all data is loaded', () => { - const userStore = RemoteDataStore.always(RemoteData.success({ name: 'Alice' })); - const postsStore = RemoteDataStore.always(RemoteData.success([{ title: 'Hello world' }])); + const userStore = RemoteDataStore.of(RemoteData.success({ name: 'Alice' })); + const postsStore = RemoteDataStore.of(RemoteData.success([{ title: 'Hello world' }])); const combined = RemoteDataStore.all(userStore, postsStore); render(); @@ -93,9 +93,9 @@ test('dashboard renders when all data is loaded', () => { }); test('dashboard shows error when one request fails', () => { - const userStore = RemoteDataStore.always(RemoteData.success({ name: 'Alice' })); + const userStore = RemoteDataStore.of(RemoteData.success({ name: 'Alice' })); // Failure.unexpected() wraps a crash; see "Failures and Retries" for details - const postsStore = RemoteDataStore.always( + const postsStore = RemoteDataStore.of( RemoteData.Failed([Failure.unexpected(new Error('timeout'))], async () => {}) ); @@ -114,9 +114,7 @@ You can test how your component looks when data is stale: ```tsx test('shows dimmed text during background refresh', () => { - const staleStore = RemoteDataStore.always( - RemoteData.StalePending(RemoteData.Success({ name: 'Alice' }, new Date())) - ); + const staleStore = RemoteDataStore.of(RemoteData.StalePending(RemoteData.Success({ name: 'Alice' }, new Date()))); render(); // The data is still visible (it's stale, not gone) @@ -133,14 +131,14 @@ The same approach works for Storybook. Each story is a different state: import { Failure, RemoteData, RemoteDataStore } from 'use-remote-data'; export const Loaded = () => ( - + ); -export const Loading = () => ; +export const Loading = () => ; export const Failed = () => ( {}) )} /> @@ -148,7 +146,7 @@ export const Failed = () => ( export const Refreshing = () => ( ` 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 means the component has no idea whether the store came from a hook, from `RemoteDataStore.of()`, 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/docusaurus.config.js b/site/docusaurus.config.js index 749d957..02cbfb7 100644 --- a/site/docusaurus.config.js +++ b/site/docusaurus.config.js @@ -26,6 +26,11 @@ module.exports = { position: 'left', label: 'Docs', }, + { + to: 'blog', + position: 'left', + label: 'Blog', + }, { href: 'https://github.com/oyvindberg/use-remote-data', label: 'GitHub', diff --git a/site/src/pages/index.js b/site/src/pages/index.js index ce5e527..4f63544 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -198,7 +198,7 @@ const codeTesting = ` import { RemoteData, RemoteDataStore, Failure } from "use-remote-data"; // A store that's already loaded. No fetch, no mock. -const store = RemoteDataStore.always( +const store = RemoteDataStore.of( RemoteData.success({ name: "Alice", email: "alice@ex.com" }) ); @@ -206,8 +206,8 @@ render(); expect(screen.getByText("Alice")).toBeInTheDocument(); // Test loading? Errors? Same idea. -const loading = RemoteDataStore.always(RemoteData.Pending); -const failed = RemoteDataStore.always( +const loading = RemoteDataStore.of(RemoteData.Pending); +const failed = RemoteDataStore.of( RemoteData.Failed( [Failure.unexpected(new Error("timeout"))], async () => {} @@ -370,7 +370,7 @@ export default function Home() {
diff --git a/src/RemoteDataStore.ts b/src/RemoteDataStore.ts index a7f22d1..65c9e4f 100644 --- a/src/RemoteDataStore.ts +++ b/src/RemoteDataStore.ts @@ -3,7 +3,7 @@ import { RemoteData } from './RemoteData'; import { isDefined } from './internal/isDefined'; export interface RemoteDataStore { - // should always call this when the data inside is meant to be rendered, typically from `Await` + // should of call this when the data inside is meant to be rendered, typically from `Await` readonly triggerUpdate: () => CancelTimeout; // you can call this explicitly to force a re-fetch readonly refresh: () => void; @@ -148,8 +148,12 @@ export namespace RemoteDataStore { } } + // create a store already in the success state. Perfect for testing and storybook + export const fromValue = (value: T, storeName?: string): RemoteDataStore => + new Always(RemoteData.success(value), storeName); + // get a completely static store. Perfect for storybook and so on - export const always = (current: RemoteData, storeName?: string): RemoteDataStore => + export const of = (current: RemoteData, storeName?: string): RemoteDataStore => new Always(current, storeName); class Always implements RemoteDataStore {