Designing Frontend Components with Injected State
How I design small React UI components by injecting state instead of fetching inside them—so you can drive loading, error, and data states from the outside and combine this with Storybook and component tests.
When building frontend sections—whether for a homepage, a dashboard, or a feature—I follow a simple rule: UI components should not fetch data. They should receive their state from the outside. That way you can design, review, and test every state (loading, error, empty, success) by injecting that state into the component instead of mocking APIs or waiting for real data.
This post explains that idea, ties it to how I structure homepage/frontend sections, and shows a concrete example with a small React component plus Storybook and component tests.
The main idea: inject state, don’t fetch
A lot of React components look like this:
- They call
useEffect+useState(or a data library) and fetch inside the component. - The component is responsible for both data and UI.
That leads to:
- Hard-to-reproduce states: To see the loading or error UI, you need to slow down the network or break the API.
- Tight coupling: The component is tied to a specific endpoint and environment.
- Awkward testing: You end up mocking
fetchor your data layer instead of testing the UI.
The approach I use instead:
- Small UI component: Renders based only on props (injected state).
- State is explicit: e.g.
status: 'idle' | 'loading' | 'error' | 'success'anddata/errorpassed in. - Parent (or page) does the fetching: The section that uses the component fetches data and passes loading/error/data down.
So the UI component is a pure function of its props: given loading, it shows a skeleton; given error, it shows a message; given data, it shows the content. You never need a real API to design or test it.
How this fits homepage / section design
On a homepage (or any “section” layout), I treat each section as a composition of:
- One or more UI components that only accept injected state (and maybe callbacks).
- A section container that:
- Fetches or gets data (from API, context, or static content).
- Maps that to loading/error/success and passes it into the UI components.
So the “design surface” is the set of UI components and their possible states. The “data surface” is the parent that wires data in. That separation makes it easy to:
- Design in isolation: Build and polish the card (or list, or hero) without touching the server.
- Reuse: The same card can be used in homepage, search results, or admin.
- Test: Assert “when status is loading, we show X” without any network.
Example: a small “async” card component
Below is a minimal pattern: a card that can show loading, error, or data. All state is injected via props; the component does no fetching.
1. Define a clear state shape
We use a small type that describes what the component can receive:
// Injected state: the component only cares about these
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T }
The UI component’s props are something like: state: AsyncState<CardItem>. So the parent can pass { status: 'loading' }, { status: 'error', error: 'Something went wrong' }, or { status: 'success', data: { ... } }.
2. Implement the UI component (pure, no fetch)
The component only branches on state.status and renders:
- idle: optional placeholder or nothing.
- loading: skeleton or spinner.
- error: message (and maybe retry button if you pass an
onRetrycallback). - success: the actual content using
state.data.
No useEffect, no fetch, no useQuery inside this component. It’s a pure presentational component.
3. Use it in a section (parent fetches)
The section (e.g. “Blog” or “Featured items”) does the data work:
- It runs the fetch (or uses a hook that returns loading/error/data).
- It maps that result into
AsyncState<CardItem>and passes it to the card.
So the same card can be driven by a REST API, GraphQL, or static JSON—the card doesn’t care.
4. Storybook: drive every state by injecting props
In Storybook, you don’t need a backend. You just render the card with different props:
- Loading:
<AsyncCard state={{ status: 'loading' }} /> - Error:
<AsyncCard state={{ status: 'error', error: 'Failed to load' }} /> - Success:
<AsyncCard state={{ status: 'success', data: mockItem }} /> - Idle:
<AsyncCard state={{ status: 'idle' }} />
That gives you a living catalog of all states and makes it easy to tweak copy, layout, and accessibility. Design and product can review without running the app or mocking the server.
5. Component tests: assert behavior for each state
With state injected, tests are straightforward:
- Render with
state={{ status: 'loading' }}→ expect skeleton (or “Loading…”). - Render with
state={{ status: 'error', error: 'Oops' }}→ expect “Oops” (and optionally thatonRetryis called when the button is clicked). - Render with
state={{ status: 'success', data: mockData }}→ expect the correct content.
No fetch mocks, no timers (unless you’re testing animations). You’re testing the UI contract: “given this state, the component shows this.”
Summary
- UI components: Small, pure, state-injected. They receive loading/error/data (and callbacks) and render. No fetching inside.
- Sections/pages: Fetch (or get) data and map it to that state, then pass it down. This keeps the “frontend section” design clear and reusable.
- Storybook: Stories inject each state (idle, loading, error, success) so you can design and review all variants.
- Component tests: Assert “given this state, we get this output” and “when user clicks retry, we call this prop.” No need to mock the network.
If you build small React components and check states by injecting the state instead of fetching inside the component, you get a clean separation between data and UI, and you can drive design and tests entirely from props. The same idea applies to any frontend section on a homepage or app: keep the section’s UI components dumb and state-injected, and keep the section (or page) smart about where data comes from.
You can find a full runnable example in the repo under components/examples/async-card/: the AsyncCard component, its Storybook stories, and a component test that follow this pattern.
